I have been trying to write unit tests in a web application to test the functionality of a vue-router definition. I quickly realized that these are not actually unit tests and they have code smell.
I believe the definition of a unit test should include the follow statements:
- Unit tests need to run independently
- Unit tests should be able to be reordered in any combination
I think it would be bad practice to not follow these definitions.
Create vue application to test the code smell
We will create a vue application with vue cli because it has everything out of the box to test this code smell.
$ npm install @vue/cli
I don't recommend using global installs, that is global pollution.
$ npx vue create unit-test-project
$ cd unit-test-project
$ vue add @vue/unit-mocha
$ npm run test:unit
WEBPACK Compiling...
[=== ] 10% (building)
[=========================] 98% (after emitting)
DONE Compiled successfully in 1949ms
[=========================] 100% (completed)
WEBPACK Compiled successfully in 1949ms
MOCHA Testing...
HelloWorld.vue
√ renders props.msg when passed
1 passing (23ms)
MOCHA Tests completed successfully
Great, everything is working. We have a foundation to write out unit tests.
We need to install vue-router package now.
$ npm install vue-router
The unit tests are located in tests/unit
. Here is the code for the unit test of the code smells.
import { expect } from 'chai'
import { shallowMount, createLocalVue } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
import VueRouter from "vue-router"
const routes = [
{
path: '/',
name: 'root'
},
{
path: '/home',
name: 'home'
},
{
path: '/options',
name: 'options'
},
{
path: '/profile',
name: 'profile'
}
];
describe('Vue Router', () => {
describe('initialize a vue router instance', () => {
it('should default to root route name', () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'root'
expect(actual).to.equal(expected)
})
describe('when navigating', () => {
it("should be able to access home", () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
wrapper.vm.$router.push('home')
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'home'
expect(actual).to.equal(expected)
})
it("should be able to access options from root path", () => {
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
const currentRouteName = wrapper.vm.$router.currentRoute.name
expect(currentRouteName).to.equal('root')
wrapper.vm.$router.push('options')
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'options'
expect(actual).to.equal(expected)
})
})
})
})
I delete the unit test file that is already there because we do not need it for this test.
I created a routes array with the routes that I want to test inside a vue router. Say if we declare these routes in our web application and we want to make sure they are all accessible. We should be able to initialize our routes and be able to navigate to them within the vue router unit test.
This is were the code starts to smell.
We first write a unit test that we can access home. Great that unit test passes.
Now let us write a should be able to access options from root path
. Maybe we have had a bug lately that someone on the root path cannot route to options.
$ npm run test:unit
Vue Router
initialize a vue router instance
√ should default to root route name
when navigating
√ should be able to access home
1) should be able to access options from root path
2 passing (108ms)
1 failing
1) Vue Router
initialize a vue router instance
when navigating
should be able to access options from root path:
AssertionError: expected 'home' to equal 'root'
+ expected - actual
-home
+root
Wait what? I have a unit test that passes saying that when the vue router initializes the current route is /
and the name is root
.
This is why the code smells, we thought we were writing a unit test to test the functionality of the vue router. What we see is a unit test N+1 being effected by a unit test N. This goes against our definition of our unit test stated above.
Try commenting out the unit test should be able to access home
and rerun it. It will work.
DONE Compiled successfully in 1723ms
[=========================] 100% (completed)
WEBPACK Compiled successfully in 1723ms
MOCHA Testing...
Vue Router
initialize a vue router instance
√ should default to root route name
when navigating
√ should be able to access options from root path
2 passing (36ms)
MOCHA Tests completed successfully
Okay, well what am I supposed to do? Why is this happening? I created my own localVue, I created my own VueRouter instance. Why is the vue router being edited across unit tests?
I check the vue test util documentation
createLocalVue returns a Vue class for you to add components, mixins and install plugins without polluting the global Vue class.
Okay great, just what I want!
No way it could be my VueRouter
instance, it is a new object everytime! I'll prove it!
I'll add a key to the objects to prove they are different and console log each object with that key
localVue.myVar = "I'll prove it!"
console.log('localVue',localVue.myVar)
localVue.use(VueRouter)
const router = new VueRouter({routes})
router.myVar = 42
console.log('Router',router.myVar)
// I also console.log the router, and localVue in the next 2 unit tests
Vue Router
initialize a vue router instance
localVue I'll prove it!
Router 42
√ should default to root route name
when navigating
localVue undefined
Router undefined
√ should be able to access home
localVue undefined
Router undefined
1) should be able to access options from root path
Okay, what gives? localVue
and router
are always new instances. They aren't shared across the unit tests.
Wait what is shared? VueRouter
, but wait! I created my own createLocalVue
and my own router
instance how is it possible the VueRouter
that I am using is shared across all of them. I literally write a line of code to initialize a new router object in the shallowMount
!
What happens if I add some variables to the VueRouter
object that is imported at the top level.
import VueRouter from "vue-router"
VueRouter.myVar = "Why is this happening?"
Vue Router
initialize a vue router instance
import VueRouter Why is this happening?
localVue I'll prove it!
Router 42
√ should default to root route name
when navigating
import VueRouter Why is this happening?
localVue undefined
Router undefined
√ should be able to access home
import VueRouter Why is this happening?
localVue undefined
Router undefined
1) should be able to access options from root path
Well duhh that should print, the scoping of VueRouter
import is at the top level of the file. What did you expect? Okay but what about the VueRouter
that is then used
inside my localVue.
We can take a look at the documentation for Vue.use
https://vuejs.org/v2/api/#Vue-use
When this method is called on the same plugin multiple times, the plugin will be installed only once.
Well I have 3 different Vue instances, I proved it!
More documentation :
https://vuejs.org/v2/guide/plugins.html
Vue.use automatically prevents you from using the same plugin more than once, so calling it multiple times on the same plugin will install the plugin only once.
Well how do I make the VueRouters
not talk to each other?
After doing some some research,
https://stackoverflow.com/questions/37459565/es6-import-duplicates
https://tc39.es/ecma262/#sec-hostresolveimportedmodule
Each time this operation is called with a specific referencingScriptOrModule, specifier pair as arguments it must return the same Module Record instance if it completes normally.
Can we somehow create a new instance and use that instead of using one import statement above?
// index.js inside the folder createLocalVueRouter
export const createLocalVueRouter = () => {
delete require.cache[require.resolve('vue-router')];
return require('vue-router').default
}
I know that you can delete the cache in node.js and re-require the module for it retrieves a new instance.
Okay we should make some new unit tests now knowing we can have 3 seperate vue router import statements for the vue routers are dependent on each other.
import { expect } from 'chai'
import { shallowMount, createLocalVue } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'
import {createLocalVueRouter} from "../../createLocalVueRouter"
const routes = [
{
path: '/',
name: 'root'
},
{
path: '/home',
name: 'home'
},
{
path: '/options',
name: 'options'
},
{
path: '/profile',
name: 'profile'
}
];
describe('Vue Router', () => {
describe('initialize a vue router instance', () => {
it('should default to root route name', () => {
const localVueRouter = createLocalVueRouter()
const localVue = createLocalVue()
localVue.use(localVueRouter)
const router = new localVueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'root'
expect(actual).to.equal(expected)
})
describe('when navigating', () => {
it("should be able to access home", () => {
const localVueRouter = createLocalVueRouter()
const localVue = createLocalVue()
localVue.use(localVueRouter)
const router = new localVueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
wrapper.vm.$router.push('home')
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'home'
expect(actual).to.equal(expected)
})
it("should be able to access options from root path", () => {
const localVueRouter = createLocalVueRouter()
const localVue = createLocalVue()
localVue.use(localVueRouter)
const router = new localVueRouter({routes})
const wrapper = shallowMount(HelloWorld, { localVue, router})
const currentRouteName = wrapper.vm.$router.currentRoute.name
expect(currentRouteName).to.equal('root')
wrapper.vm.$router.push('options')
const actual = wrapper.vm.$router.currentRoute.name
const expected = 'options'
expect(actual).to.equal(expected)
})
})
})
})
$ npm run test:unit
MOCHA Testing...
Vue Router
initialize a vue router instance
√ should default to root route name
when navigating
√ should be able to access home
1) should be able to access options from root path
2 passing (107ms)
1 failing
1) Vue Router
initialize a vue router instance
when navigating
should be able to access options from root path:
AssertionError: expected 'home' to equal 'root'
+ expected - actual
-home
+root
Why is this happening?
vue-router actually uses global variables. You can't uncache and reload the component with module requires. Why would you think you can do that? The module still is accessing and using global variables. You can't reload the module.
Look at the function getHash
function getHash () {
// We can't use window.location.hash here because it's not
// consistent across browsers - Firefox will pre-decode it!
var href = window.location.href;
var index = href.indexOf('#');
// empty path
if (index < 0) { return '' }
href = href.slice(index + 1);
// decode the hash but not the search or hash
// as search(query) is already decoded
// https://github.com/vuejs/vue-router/issues/2708
var searchIndex = href.indexOf('?');
if (searchIndex < 0) {
var hashIndex = href.indexOf('#');
if (hashIndex > -1) {
href = decodeURI(href.slice(0, hashIndex)) + href.slice(hashIndex);
} else { href = decodeURI(href); }
} else {
href = decodeURI(href.slice(0, searchIndex)) + href.slice(searchIndex);
}
return href
}
var href = window.location.href;
Great, global variables.
If you print the value of href
each time in our unit test above this is what you will see
Vue Router
initialize a vue router instance
http://localhost/
http://localhost/#/
http://localhost/#/
√ should default to root route name
when navigating
http://localhost/#/
http://localhost/#/
http://localhost/#/
http://localhost/#/home
√ should be able to access home
http://localhost/#/home
http://localhost/#/home
http://localhost/#/home
1) should be able to access options from root path
You can see that the window.location.href
is being used across all the unit tests. window.location.href
isn't even vue-router
that is from JSDOM.
We can update our createLocalVueRouter
function to account for this global variable.
export const createLocalVueRouter = () => {
delete require.cache[require.resolve('vue-router')];
if (window && window.location && window.location.href) {
window.location.href = '/'
}
return require('vue-router').default
}
All the tests pass now.
$ npm run test:unit
MOCHA Testing...
Vue Router
initialize a vue router instance
√ should default to root route name
when navigating
√ should be able to access home
√ should be able to access options from root path
3 passing (174ms)
MOCHA Tests completed successfully
This is just a mess. We have a module that relies on global variables and the state isn't being restored in normal usage. You cannot easily write a unit test because of this global variable. The global variable actually comes from JSDOM.
https://www.npmjs.com/package/@vue/cli-plugin-unit-mocha
Note the tests are run inside Node.js with browser environment simulated with JSDOM.
Now I thought to myself how is vue-router writing it's unit tests for vue-router if I ran into this issue? I checked out their repo myself and looked into the unit tests.
https://github.com/vuejs/vue-route
I installed the repo and found this file discrete-components.spec.js
. You will see that they are testing a similar unit test to what I want to test and it passes. Why does theirs work but not mine?
Oh what they don't tell you is they are actually unit testing their code without global variables.
https://router.vuejs.org/guide/essentials/history-mode.html#example-server-configurations
The default mode for vue-router is hash mode - it uses the URL hash to simulate a full URL so that the page won't be reloaded when the URL changes.
https://github.com/vuejs/vue-router/blob/65de048ee9f0ebf899ae99c82b71ad397727e55d/src/index.js#L44
let mode = options.mode || 'hash'
this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract'
}
this.mode = mode
No you aren't! You are using 'abstract'. You aren't using hash mode in your unit tests. Well if you look at the code if you aren't in a browser it will default to abstract. The documentation they provide is misleading.
var inBrowser = typeof window !== 'undefined';
JSDOM creates a global window object in the mocha unit tests. That means it will be in hash
mode in our unit tests.
They are using the abstract
mode which is used Node.js environments.
If I initialize all my VueRouter instances to use mode: abstract
my unit tests would pass but that is not the point!
The vue-router
module says the browser is determined by the window object. JSDOM reimplements the DOM by creating a window object all while you are in Node.js.
You are telling me that we are using Node.js but then using a browser environment so VueRouter goes to hash mode. So now the unit tests are dependant on one another because the entire environment is in browser mode. It won't default to abstract mode.
This can be confusing, you are in Node.js but you are in a simulated browser at the same time. How can you effecetively write unit tests that don't effect one another? I wrote unit tests that import and create local variables that shouldn't be scoped into a function but it actually is because we are sharing a global variable that is window.location.href
. This is utterly annoying. I could have easily written abstract
within the initialization statement. I could have easily written window.location.href = ''
after each it
function to clear the history. I could have easily imported variables that are not scoped into another function to write my unit test but I can't. This library effects global storage and it doesn't really tell you.
I say easily after spending a long time debugging why this problem occurs and what are potential solutions.
You cannot simulate a new window.location.href
with a new VueRouter
object across the browser environment in each unit test with how they have implemented their library. It is really annoying.
I don't want one VueRouter to effect another VueRouter clearly that is asking for too much.
My current solution to this problem.
export const createLocalVueRouter = () => {
delete require.cache[require.resolve('vue-router')];
if (window && window.location && window.location.href) {
window.location.href = '/'
}
return require('vue-router').default
}
JSDOM extra information
What really is happening is that within each unit test the JSDOM does not get cleared. VueRouter doesn't care or know about that it is just using whatever is in window.location
. What do either test runners or others have to say about clearing JSDOM between unit tests?
JSDOM does not clear after every unit test
No. Jest does not clean the JSDOM document after each test run! It only clears the DOM after all tests inside an entire file are completed.
We are using mocha but the same idea applies.
Someone had the same idea as me but gets shut down
https://github.com/facebook/jest/issues/1224
I suggest if you need this, to either:
- Write your own cleanup handler.
- Create a separate test file.
Does this work for you? At this point I'm quite hesitant to adding new APIs to support jsdom better.
NPM package
I created an NPM package for this solution as well.
It can be found here