This book is written for Vue.js 2 and Vue Test Utils v1.

Find the Vue.js 3 version here.

Vue Router

Since a router usually involves multiple components operating together, often routing tests take place further up the testing pyramidVue Router - 图1, right up at the e2e/integration test level. However, having some unit tests around your routing can be beneficial as well.

Much like previous sections discuss, there are two ways to test components that interact with a router:

  1. Using an real router instance
  2. Mocking the $route and $router global objects

Since most Vue applications use the official Vue Router, this guide will focus on that.

The source code for the tests described on this page can be found hereVue Router - 图2 and hereVue Router - 图3.

Creating the Components

We will build a simple <App>, that has a /nested-child route. Visiting /nested-child renders a <NestedRoute> component. Create an App.vue file, and insert the following minimal component:

  1. <template>
  2. <div id="app">
  3. <router-view />
  4. </div>
  5. </template>
  6. <script>
  7. export default {
  8. name: 'app'
  9. }
  10. </script>

<NestedRoute> is equally as minimal:

  1. <template>
  2. <div>Nested Route</div>
  3. </template>
  4. <script>
  5. export default {
  6. name: "NestedRoute"
  7. }
  8. </script>

Creating the Router and Routes

Now we need some routes to test. Let’s start with the routes:

  1. import NestedRoute from "@/components/NestedRoute.vue"
  2. export default [
  3. { path: "/nested-route", component: NestedRoute }
  4. ]

In a real app, you normally would create a router.js file and import the routes we made, and write something like this:

  1. import Vue from "vue"
  2. import VueRouter from "vue-router"
  3. import routes from "./routes.js"
  4. Vue.use(VueRouter)
  5. export default new VueRouter({ routes })

Since we do not want to polluate the global namespace by calling Vue.use(...) in our tests, we will create the router on a test by test basis. This will let us have more fine grained control over the state of the application during the unit tests.

Writing the Test

Let’s look at some code, then talk about what’s going on. We are testing App.vue, so in App.spec.js add the following:

  1. import { shallowMount, mount, createLocalVue } from "@vue/test-utils"
  2. import App from "@/App.vue"
  3. import VueRouter from "vue-router"
  4. import NestedRoute from "@/components/NestedRoute.vue"
  5. import routes from "@/routes.js"
  6. const localVue = createLocalVue()
  7. localVue.use(VueRouter)
  8. describe("App", () => {
  9. it("renders a child component via routing", async () => {
  10. const router = new VueRouter({ routes })
  11. const wrapper = mount(App, {
  12. localVue,
  13. router
  14. })
  15. router.push("/nested-route")
  16. await wrapper.vm.$nextTick()
  17. expect(wrapper.findComponent(NestedRoute).exists()).toBe(true)
  18. })
  19. })
  • Notice the tests are marked await and call nextTick. See here for more details on why.

As usual, we start by importing the various modules for the test. Notably, we are importing the actual routes we will be using for the application. This is ideal in some ways - if the real routing breaks, the unit tests should fail, letting us fix the problem before deploying the application.

We can use the same localVue for all the <App> tests, so it is declared outside the first describe block. However, since we might like to have different tests for different routes, the solution is not as simple as defining the router inside the it block.

Even if you put the router inside the it block the router will still point to the previous path. You can try it on the example below:

  1. import { shallowMount, mount, createLocalVue } from "@vue/test-utils"
  2. import App from "@/App.vue"
  3. import VueRouter from "vue-router"
  4. import NestedRoute from "@/components/NestedRoute.vue"
  5. import routes from "@/routes.js"
  6. const localVue = createLocalVue()
  7. localVue.use(VueRouter)
  8. describe("App", () => {
  9. it("renders a child component via routing", async () => {
  10. const router = new VueRouter({ routes })
  11. const wrapper = mount(App, {
  12. localVue,
  13. router
  14. })
  15. router.push("/nested-route")
  16. await wrapper.vm.$nextTick()
  17. expect(wrapper.findComponent(NestedRoute).exists()).toBe(true)
  18. });
  19. it("should have a different route that /nested-route", async () => {
  20. const router = new VueRouter({ routes })
  21. const wrapper = mount(App, {
  22. localVue,
  23. router
  24. })
  25. // This test will fail because we are still on the /nested-route
  26. expect(wrapper.findComponent(NestedRoute).exists()).toBe(false)
  27. console.log(router.currentRoute)
  28. })
  29. })

And the solution is to define the mode as history or abstract.

  1. const router = new VueRouter({ routes, mode: 'abstract' });

Now the current path will be the home path.

  1. {
  2. name: null,
  3. meta: {},
  4. path: '/',
  5. hash: '',
  6. query: {},
  7. params: {},
  8. fullPath: '/',
  9. matched: []
  10. }

Another notable point that is different from other guides in this book is we are using mount instead of shallowMount. If we use shallowMount, <router-link> will be stubbed out, regardless of the current route, a useless stub component will be rendered.

Workaround for large render trees using mount

Using mount is fine in some cases, but sometimes it is not ideal. For example, if you are rendering your entire <App> component, chances are the render tree is large, containing many components with their own children components and so on. A lot of children components will trigger various lifecycle hooks, making API requests and the such.

If you are using Jest, its powerful mocking system provides an elegent solution to this problem. You can simply mock the child components, in this case <NestedRoute>. The following mock can be used and the above test will still pass:

  1. jest.mock("@/components/NestedRoute.vue", () => ({
  2. name: "NestedRoute",
  3. render: h => h("div")
  4. }))

Using a Mock Router

Sometimes a real router is not necessary. Let’s update <NestedRoute> to show a username based on the current path’s query string. This time we will use TDD to implement the feature. Here is a basic test that simply renders the component and makes an assertion:

  1. import { shallowMount } from "@vue/test-utils"
  2. import NestedRoute from "@/components/NestedRoute.vue"
  3. import routes from "@/routes.js"
  4. describe("NestedRoute", () => {
  5. it("renders a username from query string", () => {
  6. const username = "alice"
  7. const wrapper = shallowMount(NestedRoute)
  8. expect(wrapper.find(".username").text()).toBe(username)
  9. })
  10. })

We don’t have a <div class="username"> yet, so running the test gives us:

  1. FAIL tests/unit/NestedRoute.spec.js
  2. NestedRoute
  3. renders a username from query string (25ms)
  4. NestedRoute renders a username from query string
  5. [vue-test-utils]: find did not return .username, cannot call text() on empty Wrapper

Update <NestedRoute>:

  1. <template>
  2. <div>
  3. Nested Route
  4. <div class="username">
  5. {{ $route.params.username }}
  6. </div>
  7. </div>
  8. </template>

Now the test fails with:

  1. FAIL tests/unit/NestedRoute.spec.js
  2. NestedRoute
  3. renders a username from query string (17ms)
  4. NestedRoute renders a username from query string
  5. TypeError: Cannot read property 'params' of undefined

This is because $route does not exist. We could use a real router, but in this case it is easier to just use the mocks mounting option:

  1. it("renders a username from query string", () => {
  2. const username = "alice"
  3. const wrapper = shallowMount(NestedRoute, {
  4. mocks: {
  5. $route: {
  6. params: { username }
  7. }
  8. }
  9. })
  10. expect(wrapper.find(".username").text()).toBe(username)
  11. })

Now the test passes. In this case, we don’t do any navigation or anything that relies on the implementation of the router, so using mocks is good. We don’t really care how username comes to be in the query string, only that it is present.

Often the server will provide the routing, as opposed to client side routing with Vue Router. In such cases, using mocks to set the query string in a test is a good alternative to using a real instance of Vue Router.

Strategies for Testing Router Hooks

Vue Router provides several types of router hooks, called “navigation guards”Vue Router - 图4. Two such examples are:

  1. Global guards (router.beforeEach). Declared on the router instance.
  2. In component guards, such as beforeRouteEnter. Declared in components.

Making sure these behave correctly is usually a job for an integration test, since you need to have a user navigate from one route to another. However, you can also use unit tests to see if the functions called in the navigation guards are working correctly and get faster feedback about potential bugs. Here are some strategies on decoupling logic from nagivation guards, and writing unit tests around them.

Global Guards

Let’s say you have a bustCache function that should be called on every route that contains the shouldBustCache meta field. You routes might look like this:

  1. import NestedRoute from "@/components/NestedRoute.vue"
  2. export default [
  3. {
  4. path: "/nested-route",
  5. component: NestedRoute,
  6. meta: {
  7. shouldBustCache: true
  8. }
  9. }
  10. ]

Using the shouldBustCache meta field, you want to invalidate the current cache to ensure the user does not get stale data. An implementation might look like this:

  1. import Vue from "vue"
  2. import VueRouter from "vue-router"
  3. import routes from "./routes.js"
  4. import { bustCache } from "./bust-cache.js"
  5. Vue.use(VueRouter)
  6. const router = new VueRouter({ routes })
  7. router.beforeEach((to, from, next) => {
  8. if (to.matched.some(record => record.meta.shouldBustCache)) {
  9. bustCache()
  10. }
  11. next()
  12. })
  13. export default router

In your unit test, you could import the router instance, and attempt to call beforeEach by typing router.beforeHooks[0](). This will throw an error about next - since you didn’t pass the correct arguments. Instead of this, one strategy is to decouple and independently export the beforeEach navigation hook, before coupling it to the router. How about:

  1. export function beforeEach(to, from, next) {
  2. if (to.matched.some(record => record.meta.shouldBustCache)) {
  3. bustCache()
  4. }
  5. next()
  6. }
  7. router.beforeEach((to, from, next) => beforeEach(to, from, next))
  8. export default router

Now writing a test is easy, albeit a little long:

  1. import { beforeEach } from "@/router.js"
  2. import mockModule from "@/bust-cache.js"
  3. jest.mock("@/bust-cache.js", () => ({ bustCache: jest.fn() }))
  4. describe("beforeEach", () => {
  5. afterEach(() => {
  6. mockModule.bustCache.mockClear()
  7. })
  8. it("busts the cache when going to /user", () => {
  9. const to = {
  10. matched: [{ meta: { shouldBustCache: true } }]
  11. }
  12. const next = jest.fn()
  13. beforeEach(to, undefined, next)
  14. expect(mockModule.bustCache).toHaveBeenCalled()
  15. expect(next).toHaveBeenCalled()
  16. })
  17. it("does not bust the cache when going to /user", () => {
  18. const to = {
  19. matched: [{ meta: { shouldBustCache: false } }]
  20. }
  21. const next = jest.fn()
  22. beforeEach(to, undefined, next)
  23. expect(mockModule.bustCache).not.toHaveBeenCalled()
  24. expect(next).toHaveBeenCalled()
  25. })
  26. })

The main point of interest is we mock the entire module using jest.mock, and reset the mock using the afterEach hook. By exporting the beforeEach as a decoupled, regular JavaScript function, it become trivial to test.

To ensure the hook is actually calling bustCache and showing the most recent data, a e2e testing tool like Cypress.ioVue Router - 图5, which comes with applications scaffolded using vue-cli, can be used.

Component Guards

Component Guards are also easy to test, once you see them as decoupled, regular JavaScript functions. Let’s say we added a beforeRouteLeave hook to <NestedRoute>:

  1. <script>
  2. import { bustCache } from "@/bust-cache.js"
  3. export default {
  4. name: "NestedRoute",
  5. beforeRouteLeave(to, from, next) {
  6. bustCache()
  7. next()
  8. }
  9. }
  10. </script>

We can test this in exactly the same way as the global guard:

  1. // ...
  2. import NestedRoute from "@/components/NestedRoute.vue"
  3. import mockModule from "@/bust-cache.js"
  4. jest.mock("@/bust-cache.js", () => ({ bustCache: jest.fn() }))
  5. it("calls bustCache and next when leaving the route", async () => {
  6. const wrapper = shallowMount(NestedRoute);
  7. const next = jest.fn()
  8. NestedRoute.beforeRouteLeave.call(wrapper.vm, undefined, undefined, next)
  9. await wrapper.vm.$nextTick()
  10. expect(mockModule.bustCache).toHaveBeenCalled()
  11. expect(next).toHaveBeenCalled()
  12. })

While this style of unit test can be useful for immediate feedback during development, since routers and navigation hooks often interact with several components to achieve some effect, you should also have integration tests to ensure everything is working as expected.

Conclusion

This guide covered:

  • testing components conditionally rendered by Vue Router
  • mocking Vue components using jest.mock and localVue
  • decoupling global navigation guards from the router and testing the independently
  • using jest.mock to mock a module

The source code for the test described on this page can be found hereVue Router - 图6 and hereVue Router - 图7.