Skip to content

How to Test Vue Router Components with Testing Library and Vitest

Published: at 

TLDR

This guide shows you how to test Vue Router components using real router integration and isolated component testing with mocks. You’ll learn to verify router-link interactions, programmatic navigation, and navigation guard handling.

Introduction

Modern Vue applications need thorough testing to ensure reliable navigation and component performance. We’ll cover testing strategies using Testing Library and Vitest to simulate real-world scenarios through router integration and component isolation.

Vue Router Testing Techniques with Testing Library and Vitest

Let’s explore how to write effective tests for Vue Router components using both real router instances and mocks.

Testing Vue Router Navigation Components

<!-- NavigationMenu.vue -->
<script setup lang="ts">
import { import useRouteruseRouter } from 'vue-router'

const const router: anyrouter = import useRouteruseRouter()
const const goToProfile: () => voidgoToProfile = () => {
  const router: anyrouter.push('/profile')
}
</script>

<template>
  <nav: HTMLAttributes & ReservedPropsnav>
    <type RouterLink: unknownrouter-link to: stringto="/dashboard" class: stringclass="nav-link">Dashboard</router-link>
    <type RouterLink: unknownrouter-link to: stringto="/settings" class: stringclass="nav-link">Settings</router-link>
    <button: ButtonHTMLAttributes & ReservedPropsbutton @onClick?: ((payload: MouseEvent) => void) | undefinedclick="const goToProfile: () => voidgoToProfile">Profile</button: ButtonHTMLAttributes & ReservedPropsbutton>
  </nav: HTMLAttributes & ReservedPropsnav>
</template>

Real Router Integration Testing

Test complete routing behavior with a real router instance:

import { import renderrender, import screenscreen } from '@testing-library/vue'
import { import describedescribe, import itit, import expectexpect } from 'vitest'
import { import createRoutercreateRouter, import createWebHistorycreateWebHistory } from 'vue-router'
import import NavigationMenuNavigationMenu from '../NavigationMenu..vue'
import { import userEventuserEvent } from '@testing-library/user-event'

import describedescribe('NavigationMenu', () => {
  import itit('should navigate using router links', async () => {
    const const router: anyrouter = import createRoutercreateRouter({
      history: anyhistory: import createWebHistorycreateWebHistory(),
      
routes: {
    path: string;
    component: {
        template: string;
    };
}[]
routes
: [
{ path: stringpath: '/dashboard',
component: {
    template: string;
}
component
: { template: stringtemplate: 'Dashboard' } },
{ path: stringpath: '/settings',
component: {
    template: string;
}
component
: { template: stringtemplate: 'Settings' } },
{ path: stringpath: '/profile',
component: {
    template: string;
}
component
: { template: stringtemplate: 'Profile' } },
{ path: stringpath: '/',
component: {
    template: string;
}
component
: { template: stringtemplate: 'Home' } },
], }) import renderrender(import NavigationMenuNavigationMenu, {
global: {
    plugins: any[];
}
global
: {
plugins: any[]plugins: [const router: anyrouter], }, }) const const user: anyuser = import userEventuserEvent.setup() import expectexpect(const router: anyrouter.currentRoute.value.path).toBe('/') await const router: anyrouter.isReady() await const user: anyuser.click(import screenscreen.getByText('Dashboard')) import expectexpect(const router: anyrouter.currentRoute.value.path).toBe('/dashboard') await const user: anyuser.click(import screenscreen.getByText('Profile')) import expectexpect(const router: anyrouter.currentRoute.value.path).toBe('/profile') }) })

Mocked Router Testing

Test components in isolation with router mocks:

import { import renderrender, import screenscreen } from '@testing-library/vue'
import { import useRouteruseRouter, type import RouterRouter } from 'vue-router'
import { import describedescribe, import itit, import expectexpect, import vivi } from 'vitest'
import import NavigationMenuNavigationMenu from '../NavigationMenu..vue'
import import userEventuserEvent from '@testing-library/user-event'

const const mockPush: anymockPush = import vivi.fn()
import vivi.mock('vue-router', () => ({
  useRouter: anyuseRouter: import vivi.fn(),
}))

import describedescribe('NavigationMenu with mocked router', () => {
  import itit('should handle navigation with mocked router', async () => {
    const const mockRouter: RoutermockRouter = {
      push: anypush: const mockPush: anymockPush,
      
currentRoute: {
    value: {
        path: string;
    };
}
currentRoute
: {
value: {
    path: string;
}
value
: { path: stringpath: '/' } },
} as unknown as import RouterRouter import vivi.mocked(import useRouteruseRouter).mockImplementation(() => const mockRouter: RoutermockRouter) const const user: anyuser = import userEventuserEvent.setup() import renderrender(import NavigationMenuNavigationMenu) await const user: anyuser.click(import screenscreen.getByText('Profile')) import expectexpect(const mockPush: anymockPush).toHaveBeenCalledWith('/profile') }) })

Create a RouterLink stub to test navigation without router-link behavior:

// test-utils.ts
import { type Component<Props = any, RawBindings = any, D = any, C extends ComputedOptions = ComputedOptions, M extends MethodOptions = MethodOptions, E extends EmitsOptions | Record<string, any[]> = {}, S extends Record<string, any> = any> = ConcreteComponent<Props, RawBindings, D, C, M, E, S> | ComponentPublicInstanceConstructor<Props, any, any, any, ComputedOptions, MethodOptions>
A type used in public APIs where a component type is expected. The constructor type is an artificial type returned by defineComponent().
Component
, function h<K extends keyof HTMLElementTagNameMap>(type: K, children?: RawChildren): VNode (+22 overloads)h } from 'vue'
import { import useRouteruseRouter } from 'vue-router' export const const RouterLinkStub: ComponentRouterLinkStub: type Component<Props = any, RawBindings = any, D = any, C extends ComputedOptions = ComputedOptions, M extends MethodOptions = MethodOptions, E extends EmitsOptions | Record<string, any[]> = {}, S extends Record<string, any> = any> = ConcreteComponent<Props, RawBindings, D, C, M, E, S> | ComponentPublicInstanceConstructor<Props, any, any, any, ComputedOptions, MethodOptions>
A type used in public APIs where a component type is expected. The constructor type is an artificial type returned by defineComponent().
Component
= {
name: stringname: 'RouterLinkStub', FunctionalComponent<any, {}, any, {}>.props?: ComponentPropsOptions<any> | undefinedprops: {
to: {
    type: (StringConstructor | ObjectConstructor)[];
    required: true;
}
to
: {
PropOptions<any, any>.type?: true | PropType<any> | null | undefinedtype: [var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
, var Object: ObjectConstructor
Provides functionality common to all JavaScript objects.
Object
],
PropOptions<any, any>.required?: boolean | undefinedrequired: true, },
tag: {
    type: StringConstructor;
    default: string;
}
tag
: {
PropOptions<any, any>.type?: true | PropType<any> | null | undefinedtype: var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
,
PropOptions<any, any>.default?: anydefault: 'a', }, exact: BooleanConstructorexact: var Boolean: BooleanConstructorBoolean, exactPath: BooleanConstructorexactPath: var Boolean: BooleanConstructorBoolean, append: BooleanConstructorappend: var Boolean: BooleanConstructorBoolean, replace: BooleanConstructorreplace: var Boolean: BooleanConstructorBoolean, activeClass: StringConstructoractiveClass: var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
,
exactActiveClass: StringConstructorexactActiveClass: var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
,
exactPathActiveClass: StringConstructorexactPathActiveClass: var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
,
event: {
    type: (StringConstructor | ArrayConstructor)[];
    default: string;
}
event
: {
PropOptions<any, any>.type?: true | PropType<any> | null | undefinedtype: [var String: StringConstructor
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
, var Array: ArrayConstructorArray],
PropOptions<any, any>.default?: anydefault: 'click', }, },
ComponentOptionsBase<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions>.setup?: ((this: void, props: LooseRequired<any>, ctx: {
    attrs: Data;
    slots: Readonly<InternalSlots>;
    emit: ((event: unknown, ...args: any[]) => void) | ((event: string, ...args: any[]) => void);
    expose: <Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed) => void;
}) => any) | undefined
setup
(props: LooseRequired<any>props) {
const const router: anyrouter = import useRouteruseRouter() const const navigate: () => voidnavigate = () => { const router: anyrouter.push(props: LooseRequired<any>props.to) } return { navigate: () => voidnavigate } }, ComponentOptionsBase<any, any, any, ComputedOptions, MethodOptions, any, any, any, string, {}, {}, string, {}, {}, {}, string, ComponentProvideOptions>.render?: Function | undefinedrender() { return h<any>(type: any, props?: (RawProps & HTMLElementEventHandler) | null, children?: RawChildren | RawSlots): VNode (+22 overloads)h( this.tag, { onClick?: ((ev: MouseEvent) => any) | undefinedonClick: () => this.navigate(), }, this.$slots.default?.(), ) }, }

Use the RouterLinkStub in tests:

import { import renderrender, import screenscreen } from '@testing-library/vue'
import { import useRouteruseRouter, type import RouterRouter } from 'vue-router'
import { import describedescribe, import itit, import expectexpect, import vivi } from 'vitest'
import import NavigationMenuNavigationMenu from '../NavigationMenu..vue'
import import userEventuserEvent from '@testing-library/user-event'
import { import RouterLinkStubRouterLinkStub } from './test-utils'

const const mockPush: anymockPush = import vivi.fn()
import vivi.mock('vue-router', () => ({
  useRouter: anyuseRouter: import vivi.fn(),
}))

import describedescribe('NavigationMenu with mocked router', () => {
  import itit('should handle navigation with mocked router', async () => {
    const const mockRouter: RoutermockRouter = {
      push: anypush: const mockPush: anymockPush,
      
currentRoute: {
    value: {
        path: string;
    };
}
currentRoute
: {
value: {
    path: string;
}
value
: { path: stringpath: '/' } },
} as unknown as import RouterRouter import vivi.mocked(import useRouteruseRouter).mockImplementation(() => const mockRouter: RoutermockRouter) const const user: anyuser = import userEventuserEvent.setup() import renderrender(import NavigationMenuNavigationMenu, {
global: {
    stubs: {
        RouterLink: any;
    };
}
global
: {
stubs: {
    RouterLink: any;
}
stubs
: {
type RouterLink: anyRouterLink: import RouterLinkStubRouterLinkStub, }, }, }) await const user: anyuser.click(import screenscreen.getByText('Dashboard')) import expectexpect(const mockPush: anymockPush).toHaveBeenCalledWith('/dashboard') }) })

Testing Navigation Guards

Test navigation guards by rendering the component within a route context:

<script setup lang="ts">
import { import onBeforeRouteLeaveonBeforeRouteLeave } from 'vue-router'

import onBeforeRouteLeaveonBeforeRouteLeave(() => {
  return var window: Window & typeof globalThis
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)
window
.function confirm(message?: string): boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/confirm)
confirm
('Do you really want to leave this page?')
}) </script> <template> <div: HTMLAttributes & ReservedPropsdiv> <h1: HTMLAttributes & ReservedPropsh1>Route Leave Guard Demo</h1: HTMLAttributes & ReservedPropsh1> <div: HTMLAttributes & ReservedPropsdiv> <nav: HTMLAttributes & ReservedPropsnav> <type RouterLink: unknownrouter-link to: stringto="/">Home</router-link> | <type RouterLink: unknownrouter-link to: stringto="/about">About</router-link> | <type RouterLink: unknownrouter-link to: stringto="/guard-demo">Guard Demo</router-link> </nav: HTMLAttributes & ReservedPropsnav> </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> </template>

Test the navigation guard:

import { import renderrender, import screenscreen } from '@testing-library/vue'
import import userEventuserEvent from '@testing-library/user-event'
import { import describedescribe, import itit, import expectexpect, import vivi, import beforeEachbeforeEach } from 'vitest'
import { import createRoutercreateRouter, import createWebHistorycreateWebHistory } from 'vue-router'
import import RouteLeaveGuardDemoRouteLeaveGuardDemo from '../RouteLeaveGuardDemo.vue'

const 
const routes: {
    path: string;
    component: any;
}[]
routes
= [
{ path: stringpath: '/', component: anycomponent: import RouteLeaveGuardDemoRouteLeaveGuardDemo }, { path: stringpath: '/about',
component: {
    template: string;
}
component
: { template: stringtemplate: '<div>About</div>' } },
] const const router: anyrouter = import createRoutercreateRouter({ history: anyhistory: import createWebHistorycreateWebHistory(),
routes: {
    path: string;
    component: any;
}[]
routes
,
}) const
const App: {
    template: string;
}
App
= { template: stringtemplate: '<router-view />' }
import describedescribe('RouteLeaveGuardDemo', () => { import beforeEachbeforeEach(async () => { import vivi.clearAllMocks() var window: Window & typeof globalThis
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)
window
.function confirm(message?: string): boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/confirm)
confirm
= import vivi.fn()
await const router: anyrouter.push('/') await const router: anyrouter.isReady() }) import itit('should prompt when guard is triggered and user confirms', async () => { // Set window.confirm to simulate a user confirming the prompt var window: Window & typeof globalThis
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)
window
.function confirm(message?: string): boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/confirm)
confirm
= import vivi.fn(() => true)
// Render the component within a router context import renderrender(
const App: {
    template: string;
}
App
, {
global: {
    plugins: any[];
}
global
: {
plugins: any[]plugins: [const router: anyrouter], }, }) const const user: anyuser = import userEventuserEvent.setup() // Find the 'About' link and simulate a user click const const aboutLink: anyaboutLink = import screenscreen.getByRole('link', { name: RegExpname: /About/i }) await const user: anyuser.click(const aboutLink: anyaboutLink) // Assert that the confirm dialog was shown with the correct message import expectexpect(var window: Window & typeof globalThis
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)
window
.function confirm(message?: string): boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/confirm)
confirm
).toHaveBeenCalledWith('Do you really want to leave this page?')
// Verify that the navigation was allowed and the route changed to '/about' import expectexpect(const router: anyrouter.currentRoute.value.path).toBe('/about') }) })

Reusable Router Test Helper

Create a helper function to simplify router setup:

// test-utils.ts
import { import renderrender } from '@testing-library/vue'
import { import createRoutercreateRouter, import createWebHistorycreateWebHistory } from 'vue-router'
import type { import RenderOptionsRenderOptions } from '@testing-library/vue'
// path of the definition of your routes
import { import routesroutes } from '../../router/index.ts'

interface RenderWithRouterOptions extends type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
Construct a type with the properties of T except for those in type K.
Omit
<import RenderOptionsRenderOptions<any>, 'global'> {
RenderWithRouterOptions.initialRoute?: string | undefinedinitialRoute?: string
RenderWithRouterOptions.routerOptions?: {
    routes?: typeof routes;
    history?: ReturnType<typeof createWebHistory>;
} | undefined
routerOptions
?: {
routes?: anyroutes?: typeof import routesroutes history?: anyhistory?: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Obtain the return type of a function type
ReturnType
<typeof import createWebHistorycreateWebHistory>
} } export function function renderWithRouter(Component: any, options?: RenderWithRouterOptions): anyrenderWithRouter(type Component: anyComponent: any, options: RenderWithRouterOptionsoptions: RenderWithRouterOptions = {}) { const { const initialRoute: stringinitialRoute = '/',
const routerOptions: {
    routes?: typeof routes;
    history?: ReturnType<typeof createWebHistory>;
}
routerOptions
= {}, ...
const renderOptions: {
    [x: string]: RenderOptions<any>;
    [x: number]: RenderOptions<any>;
    [x: symbol]: RenderOptions<any>;
}
renderOptions
} = options: RenderWithRouterOptionsoptions
const const router: anyrouter = import createRoutercreateRouter({ history: anyhistory: import createWebHistorycreateWebHistory(), // Use provided routes or import from your router file routes: anyroutes:
const routerOptions: {
    routes?: typeof routes;
    history?: ReturnType<typeof createWebHistory>;
}
routerOptions
.routes?: anyroutes || import routesroutes,
}) const router: anyrouter.push(const initialRoute: stringinitialRoute) return { // Return everything from regular render, plus the router instance ...import renderrender(type Component: anyComponent, {
global: {
    plugins: any[];
}
global
: {
plugins: any[]plugins: [const router: anyrouter], }, ...
const renderOptions: {
    [x: string]: RenderOptions<any>;
    [x: number]: RenderOptions<any>;
    [x: symbol]: RenderOptions<any>;
}
renderOptions
,
}), router: anyrouter, } }

Use the helper in tests:

describe('NavigationMenu', () => {
  it('should navigate using router links', async () => {
    const { const router: anyrouter } = renderWithRouter(NavigationMenu, {
      initialRoute: stringinitialRoute: '/',
    })

    await const router: anyrouter.isReady()
    const const user: anyuser = userEvent.setup()
    
    await const user: anyuser.click(var screen: Screen
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/screen)
screen
.getByText('Dashboard'))
expect(const router: anyrouter.currentRoute.value.path).toBe('/dashboard') }) })

Conclusion: Best Practices for Vue Router Component Testing

When we test components that rely on the router, we need to consider whether we want to test the functionality in the most realistic use case or in isolation. In my humble opinion, the more you mock a test, the worse it will get. My personal advice would be to aim to use the real router instead of mocking it. Sometimes, there are exceptions, so keep that in mind.

Also, you can help yourself by focusing on components that don’t rely on router functionality. Reserve router logic for view/page components. While keeping our components simple, we will never have the problem of mocking the router in the first place.

Questions or thoughts?

Follow me on X for more TypeScript, Vue, and web dev insights! Feel free to DM me with:

  • Questions about this article
  • Topic suggestions
  • Feedback or improvements
Connect on X

Related Posts