/vue-router-mock

🧪 Easily mock routing interactions in your Vue apps

Primary LanguageTypeScriptMIT LicenseMIT

vue-router-mock test npm package codecov thanks

Easily mock routing interactions in your Vue 3 apps

Installation

pnpm i -D vue-router-mock
# or
yarn add -D vue-router-mock
# or
npm install -D vue-router-mock

Requirements

This library

  • @vue/test-utils >= 2.4.0
  • vue 3 and vue router 4

Goal

The goal of Vue Router Mock is to enable users to unit and integration test navigation scenarios. This means tests that are isolated enough to not be end to end tests (e.g. using Cypress) or are edge cases (e.g. network failures). Because of this, some scenarios are more interesting as end to end tests, using the real vue router.

Introduction

Vue Router Mock exposes a few functions to be used individually and they are all documented through TS. But most of the time you want to globally inject the router in a setupFilesAfterEnv file. Create a tests/router-mock-setup.js file at the root of your project (it can be named differently):

import {
  VueRouterMock,
  createRouterMock,
  injectRouterMock,
} from 'vue-router-mock'
import { config } from '@vue/test-utils'

// create one router per test file
const router = createRouterMock()
beforeEach(() => {
  router.reset() // reset the router state
  injectRouterMock(router)
})

// Add properties to the wrapper
config.plugins.VueWrapper.install(VueRouterMock)

Note: you might need to write this file in CommonJS for Jest. In Vite, you can write it in Typescript

Then add this line to your jest.config.js:

  setupFilesAfterEnv: ['<rootDir>/tests/router-mock-setup.js'],

or to your vitest.config.ts:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'happy-dom', // <- or jsdom, needed to mount Vue Components
    setupFiles: ['./tests/setup-router-mock.ts'],
  },
})

This will inject a router in all your tests. If for specific tests, you need to inject a different version of the router, you can do so:

import { createRouterMock, injectRouterMock } from 'vue-router-mock'

describe('SearchUsers', () => {
  // create one mock instance, pass options
  const router = createRouterMock({
    // ...
  })
  beforeEach(() => {
    // inject it globally to ensure `useRoute()`, `$route`, etc work
    // properly and give you access to test specific functions
    injectRouterMock(router)
  })

  it('should paginate', async () => {
    const wrapper = mount(SearchUsers)

    expect(wrapper.router).toBe(router)

    // go to the next page
    // this will internally trigger `router.push({ query: { page: 2 }})`
    wrapper.find('button.next-page').click()

    expect(wrapper.router.push).toHaveBeenCalledWith(
      expect.objectContaining({ query: { page: 2 } })
    )
    expect(wrapper.router.push).toHaveBeenCalledTimes(1)

    // if we had a navigation guard fetching the search results,
    // waiting for it to be done will allow us to wait until it's done.
    // Note you need to mock the fetch and to activate navigation
    // guards as explained below
    await router.getPendingNavigation()
    // wait for the component to render again if we want to check
    await wrapper.vm.nextTick()

    expect(wrapper.find('#user-results .user').text()).toMatchSnapshot()
  })
})

If you need to create a specific version of the router for one single test (or a nested suite of them), you should call the same functions:

it('should paginate', async () => {
  const router = createRouterMock()
  injectRouterMock(router)
  const wrapper = mount(SearchUsers)
})

Guide

Accessing the Router Mock instance

You can access the instance of the router mock in multiple ways:

  • Access wrapper.router:

    it('tests something', async () => {
      const wrapper = mount(MyComponent)
      await wrapper.router.push('/new-location')
    })
  • Access it through wrapper.vm:

    it('tests something', async () => {
      const wrapper = mount(MyComponent)
      await wrapper.vm.$router.push('/new-location')
      expect(wrapper.vm.$route.name).toBe('NewLocation')
    })
  • Call getRouter() inside of a test:

    it('tests something', async () => {
      // can be called before creating the wrapper
      const router = getRouter()
      const wrapper = mount(MyComponent)
      await router.push('/new-location')
    })

Setting parameters

setParams allows you to change route params without triggering a navigation:

it('should display the user details', async () => {
  const wrapper = mount(UserDetails)
  getRouter().setParams({ userId: 12 })

  // test...
})

It can be awaited if you need to wait for Vue to render again:

it('should display the user details', async () => {
  const wrapper = mount(UserDetails)
  await getRouter().setParams({ userId: 12 })

  // test...
})

setQuery and setHash are very similar. They can be used to set the route query or hash without triggering a navigation, and can be awaited too.

Setting the initial location

By default the router mock starts on START_LOCATION. In some scenarios this might need to be adjusted by pushing a new location and awaiting it before testing:

it('should paginate', async () => {
  await router.push('/users?q=haruno')
  const wrapper = mount(SearchUsers)

  // test...
})

You can also set the initial location for all your tests by passing an initialLocation:

const router = createRouterMock({
  initialLocation: '/users?q=jolyne',
})

initialLocation accepts anything that can be passed to router.push().

Simulating navigation failures

You can simulate the failure of the next navigation

Simulating a navigation guard

By default, all navigation guards are ignored so that you can simulate the return of the next guard by using setNextGuardReturn() without depending on existing ones:

// simulate a navigation guard that returns false
router.setNextGuardReturn(false)
// simulate a redirection
router.setNextGuardReturn('/login')

If you want to still run existing navigation guards inside component, you can active them when creating your router mock:

const router = createRouterMock({
  // run `onBeforeRouteLeave()`, `onBeforeRouteUpdate()`, `beforeRouteEnter()`, `beforeRouteUpdate()`, and `beforeRouteLeave()`
  runInComponentGuards: true,
  // run `beforeEnter` of added routes. Note that you must manually add these routes with `router.addRoutes()`
  runPerRouteGuards: true,
})

Stubs

By default, both <router-link> and <router-view> are stubbed but you can override them locally. This is specially useful when you have nested <router-view> and you rely on them for a test:

const wrapper = mount(MyComponent, {
  global: {
    stubs: { RouterView: MyNestedComponent },
  },
})

You need to manually specify the component that is supposed to be displayed because the mock won't be able to know the level of nesting.

NOTE: this might change to become automatic if the necessary routes are provided.

Testing libraries

Vue Router Mock automatically detects if you are using Sinon.js, Jest, or Vitest and use their spying methods. You can of course configure Vue Router Mock to use any spying library you want.

For example, if you use Vitest with globals: false, then you need to manually configure the spy option and pass vi.fn() to it:

const router = createRouterMock({
  spy: {
    create: fn => vi.fn(fn),
    reset: spy => spy.mockClear(),
  },
})

Caveats

Nested Routes

By default, the router mock comes with one single catch all route. You can add routes calling the router.addRoute() function but if you add nested routes and you are relying on running navigation guards, you must manually set the depth of the route you are displaying. This is because the router has no way to know which level of nesting you are trying to display. e.g. Imagine the following routes:

const routes = [
  {
    path: '/users',
    // we are not testing this one so it doesn't matter
    component: UserView,
    children: [
      // UserDetail must be the same component we are unit testing
      { path: ':id', component: UserDetail },
    ],
  },
]
// 0 would be if we were testing UserView at /users
router.depth.value = 1
const wrapper = mount(UserDetail)

Remember, this is not necessary if you are not adding routes or if they are not nested.

Related

License

MIT

This project was created using the Vue Library template by posva