Skip to content

Docs: Vue Router #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Sep 16, 2020
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 286 additions & 3 deletions src/guide/vue-router.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,288 @@
# Vue Router
## Testing with Vue Router

:)
Vue Test Utils does not provide any special functions to assist with testing components that rely on Vue Router. This article will present two ways to test an application using Vue Router - using the real Vue Router, which is more production like but also may lead to complexity when testing larger applications, and by using a mock router, allowing for more fine grained control of the testing environment.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should explicitly recommend using the mocked router as well as putting that section first

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we can recommend mocking the router. Regarding the order, my initial idea was to use a real router to show the complexity it introduces to otherwise simple unit tests, then show the solution. I don't have a really strong opinion on the order. I'll rework the article to discuss the mocked solution first.


https://github.com/vuejs/vue-test-utils-next-docs/issues/7
Let's look at how you can test with a mock router, since that is generally the simplest way to test components dependening on Vue Router. Then we will take a look at what is involved if you'd like to use are real router.

## Using a Mock Router

You can use a mock router to avoid caring about the implementation details of Vue Router in your unit tests. Instead of using a real Vue Router instance, We can create our own minimal mock version which only implements the features we are interested in. We can do this using a combination of `jest.mock` (if you are using Jest), and `global.components`.

When we mock out a dependency, it's usually because we are not interested in testing that dependencies behavior. In this case, we do not want to test clicking `<router-link>` (which is really just an `<a>` tag) navigates to the correct page - of course it does! In this example, we might be interested in ensuring that the `<a>` has the correct `to` attribute, though. This may seem trivial, but in a larger application with much more complex routing, it can be worth ensuring links are correct (especially if they are highly dynamic).

To illustrate this, let's see a different example, where mocking the router becomes much more attractive.

```js
const Component = {
template: `
<button @click="redirect">Click to Edit</button>
`,
props: ['authenticated'],
methods: {
redirect() {
if (this.authenticated) {
this.$router.push(`/posts/${this.$route.params.id}/edit`)
} else {
this.$router.push('/404')
}
}
}
}
```

This component shows a button that will redirect an authenticated user to the edit post page (based on the current route parameters). An unauthenticated user should be redirected to a `/404` route. We could use a real router, then navigate to the correct route for this component, then after clicking the button assert that the correct page is rendered... however, this is a lot of setup for a relatively simple test. At it's core, the test we want to write is "if authenticated, redirect to X, otherwise redirect to Y". Let's see how we might accomplish this by mocking the routing using the `global.mocks` property:

```js
describe('component handles routing correctly', () => {
it('allows authenticated user to edit a post', () => {
const mockRoute = {
params: {
id: 1
}
}
const mockRouter = {
push: jest.fn()
}
const wrapper = mount(Component, {
props: {
authenticated: true
},
global: {
mocks: {
$route: mockRoute,
$router: mockRouter
}
}
})

await wrapper.find('button').trigger('click')

expect(mockRouter.push).toHaveBeenCalledWith('/posts/1/edit')
})

it('redirect an unauthenticated user to 404', () => {
const mockRoute = {
params: {
id: 1
}
}
const mockRouter = {
push: jest.fn()
}
const wrapper = mount(Component, {
props: {
authenticated: false
},
global: {
mocks: {
$route: mockRoute,
$router: mockRouter
}
}
})

await wrapper.find('button').trigger('click')

expect(mockRouter.push).toHaveBeenCalledWith('/404')
})
})
```

By combining `global.mocks` to provide the necessary dependencies (`this.$route` and `this.$router`) we were able to set the test environment in an ideal state for this particular test. We were then able to use `jest.fn()` to monitor how many times, and with which arguments, `this.$router.push` was called with. Best of all, we don't have to deal with the complexity or caveats of Vue Router in our test, when really what we are concerned with testing is the logic behind the routing, not the routing itself.

Of course, you still need to test the entire system in an end-to-end manner with a real router at some point. You could consider a framework like [Cypress](https://www.cypress.io/) for full system tests using a real browser.

## With a Real Router

Now we have seen how to use am ock router, let's take a look at what is involved when using the real Vue Router. We will look at a test for a blogging application that uses Vue Router. The posts are listed on the `/posts` route. The components are as follows:

```js
const App = {
template: `
<router-link to="/posts">Go to posts</router-link>
<router-view />
`
}

const Posts = {
template: `
<h1>Posts</h1>
<ul>
<li v-for="posts in posts" :key="post.id">
{{ post.name }}
</li>
</ul>
`,
data() {
return {
posts: [{ id: 1, name: 'Testing Vue Router' }]
}
}
}
```

The root of the app displays a `<router-link>` leading to `/posts`, where we list the posts in an unordered list.

The router looks like this:

```js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: {
template: 'Welcome to the blogging app'
}
},
{
path: '/posts',
component: Posts
}
]
})
```

The best way to illustrate how to test an app using Vue Router is to let the warnings guide us. The following minimal test is enough to get us going:

```js
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'

test('routing', () => {
const wrapper = mount(App)
expect(wrapper.html()).toContain('Welcome to the blogging app')
})
```

The test fails. It also prints two warnings:

```sh
console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
[Vue warn]: Failed to resolve component: router-link
at <Anonymous ref="VTU_COMPONENT" >
at <VTUROOT>

console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
[Vue warn]: Failed to resolve component: router-view
at <Anonymous ref="VTU_COMPONENT" >
at <VTUROOT>
```

The `<router-link>` and `<router-view>` component are not found. We need to install Vue Router! Since Vue Router is a plugin, we install it using the `global.plugins` mounting option:

```js {6,7,8}
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should import router instead, am I right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The router is referring to the instance created before.

I think to make it clearer, we should show a bit of the router instance:

const router = createRouter({
  // options omitted for brevity
})

or a different comment that sounds better in English


const router = createRouter({
// omitted for brevity
})

test('routing', () => {
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.html()).toContain('Welcome to the blogging app')
})
```

Those two warnings are now gone - but now we have another warning:

```js
console.warn node_modules/vue-router/dist/vue-router.cjs.js:225
[Vue Router warn]: Unexpected error when starting the router: TypeError: Cannot read property '_history' of null
```

Although it's not entirely clear from the warning, it's related to the fact that Vue Router 4 handles routing asynchronously, and before anything is rendered, the router must be in a "ready" state. For this reason Vue Router provides an `isReady` function, which we can `await` to ensure the router is ready and the initial navigation has finished before rendering anything.

In web mode (which we are using, since we passed the `history: createWebHistory()` option when creating the router), the router will use the initial location to trigger an initial navigation automatically. For this reason, why we need to `await router.isReady()` - to ensure the initial navigation has completed before the test continues.

```js {5,6}
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'

test('routing', () => {
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.html()).toContain('Welcome to the blogging app')
})
```

The test is now passing! It was quite a bit of work - this is one of the reasons it might make more sense to use a mock router for your tests.

Now the initial setup has been handled, let's navigate to `/posts` and make an assertion to ensure the routing is working as expected:

```js {14,15}
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'

test('routing', async () => {
router.push('/')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason you need this push is because the initial navigation happens when we add the router plugin.

The reason the navigation is delayed until plugin installation is to allow the user setup navigation guards before the router triggers that initial navigation.

await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.html()).toContain('Welcome to the blogging app')

await wrapper.find('a').trigger('click')
expect(wrapper.html()).toContain('Testing Vue Router')
})
```

Again, another somewhat cryptic error:

```js
console.warn node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:39
[Vue warn]: Unhandled error during execution of native event handler
at <RouterLink to="/posts" >
at <Anonymous ref="VTU_COMPONENT" >
at <VTUROOT>

console.error node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:211
TypeError: Cannot read property '_history' of null
```

Again, due to Vue Router 4's new asynchronous nature, we need to `await` the routing to complete before making any assertions. In this case, however, there is not `router.hasNavigated` hook we can await on. One alternative is to use the `flushPromises` function exported from Vue Test Utils:

```js {2,15}
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'

test('routing', async () => {
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
expect(wrapper.html()).toContain('Welcome to the blogging app')

await wrapper.find('a').trigger('click')
await flushPromises()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, I didn't know this worked in this scenario! It still works only because there are no navigation guards. This is where having those extra router test utils would be useful. Overall I think we need a router mock (vuejs/test-utils#152) that exposes utility functions like a await router.hasNavigated()

There could still be a function that takes a router instance to wrap the helpers around it: createRouteMock(router) or something similar. I also think it's worth if this kind of behavior could be automatic in test utils to make the end experience smoother

expect(wrapper.html()).toContain('Testing Vue Router')
})
```

It *finally* passes. Great! This is all very manual, however - and this is for a tiny, trivial app. This is the reason using a mock router is a common approach when testing Vue components using Vue Test Utils.

## Conclusion

- Vue Router 4 is asynchronous. This must be considered when testing in a jsdom environment.
- You can use Vue Router in your tests. There are some caveats, but there is no technical reason why you cannot use a real router.
- For more complex applications, consider mocking the router dependency and focus on testing the underlying logic.
- Make use of your test runner's stubbing/mocking functionality where possible.
- Use `global.mocks` to mock global dependencies, such as `this.$route` and `this.$router`.