Join the Newsletter!

Exclusive content & updates. No spam.

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 { useRouter } from 'vue-router'

const router = useRouter()
const goToProfile = () => {
  router.push('/profile')
}
</script>

<template>
  <nav>
    <router-link to="/dashboard" class="nav-link">Dashboard</router-link>
    <router-link to="/settings" class="nav-link">Settings</router-link>
    <button @click="goToProfile">Profile</button>
  </nav>
</template>

Real Router Integration Testing

Test complete routing behavior with a real router instance:

import { render, screen } from '@testing-library/vue'
import { describe, it, expect } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import NavigationMenu from '../NavigationMenu..vue'
import { userEvent } from '@testing-library/user-event'

describe('NavigationMenu', () => {
  it('should navigate using router links', async () => {
    const router = createRouter({
      history: createWebHistory(),
      routes: [
        { path: '/dashboard', component: { template: 'Dashboard' } },
        { path: '/settings', component: { template: 'Settings' } },
        { path: '/profile', component: { template: 'Profile' } },
        { path: '/', component: { template: 'Home' } },
      ],
    })

    render(NavigationMenu, {
      global: {
        plugins: [router],
      },
    })

    const user = userEvent.setup()
    expect(router.currentRoute.value.path).toBe('/')

    await router.isReady()
    await user.click(screen.getByText('Dashboard'))
    expect(router.currentRoute.value.path).toBe('/dashboard')

    await user.click(screen.getByText('Profile'))
    expect(router.currentRoute.value.path).toBe('/profile')
  })
})

Mocked Router Testing

Test components in isolation with router mocks:

import { render, screen } from '@testing-library/vue'
import { useRouter, type Router } from 'vue-router'
import { describe, it, expect, vi } from 'vitest'
import NavigationMenu from '../NavigationMenu..vue'
import userEvent from '@testing-library/user-event'

const mockPush = vi.fn()
vi.mock('vue-router', () => ({
  useRouter: vi.fn(),
}))

describe('NavigationMenu with mocked router', () => {
  it('should handle navigation with mocked router', async () => {
    const mockRouter = {
      push: mockPush,
      currentRoute: { value: { path: '/' } },
    } as unknown as Router

    vi.mocked(useRouter).mockImplementation(() => mockRouter)

    const user = userEvent.setup()
    render(NavigationMenu)

    await user.click(screen.getByText('Profile'))
    expect(mockPush).toHaveBeenCalledWith('/profile')
  })
})

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

// test-utils.ts
import { Component, h } from 'vue'
import { useRouter } from 'vue-router'

export const RouterLinkStub: Component = {
  name: 'RouterLinkStub',
  props: {
    to: {
      type: [String, Object],
      required: true,
    },
    tag: {
      type: String,
      default: 'a',
    },
    exact: Boolean,
    exactPath: Boolean,
    append: Boolean,
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String,
    exactPathActiveClass: String,
    event: {
      type: [String, Array],
      default: 'click',
    },
  },
  setup(props) {
    const router = useRouter()
    const navigate = () => {
      router.push(props.to)
    }
    return { navigate }
  },
  render() {
    return h(
      this.tag,
      {
        onClick: () => this.navigate(),
      },
      this.$slots.default?.(),
    )
  },
}

Use the RouterLinkStub in tests:

import { render, screen } from '@testing-library/vue'
import { useRouter, type Router } from 'vue-router'
import { describe, it, expect, vi } from 'vitest'
import NavigationMenu from '../NavigationMenu..vue'
import userEvent from '@testing-library/user-event'
import { RouterLinkStub } from './test-utils'

const mockPush = vi.fn()
vi.mock('vue-router', () => ({
  useRouter: vi.fn(),
}))

describe('NavigationMenu with mocked router', () => {
  it('should handle navigation with mocked router', async () => {
    const mockRouter = {
      push: mockPush,
      currentRoute: { value: { path: '/' } },
    } as unknown as Router

    vi.mocked(useRouter).mockImplementation(() => mockRouter)

    const user = userEvent.setup()
    render(NavigationMenu, {
      global: {
        stubs: {
          RouterLink: RouterLinkStub,
        },
      },
    })

    await user.click(screen.getByText('Dashboard'))
    expect(mockPush).toHaveBeenCalledWith('/dashboard')
  })
})

Testing Navigation Guards

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

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

onBeforeRouteLeave(() => {
  return window.confirm('Do you really want to leave this page?')
})
</script>

<template>
  <div>
    <h1>Route Leave Guard Demo</h1>
    <div>
      <nav>
        <router-link to="/">Home</router-link> |
        <router-link to="/about">About</router-link> |
        <router-link to="/guard-demo">Guard Demo</router-link>
      </nav>
    </div>
  </div>
</template>

Test the navigation guard:

import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createRouter, createWebHistory } from 'vue-router'
import RouteLeaveGuardDemo from '../RouteLeaveGuardDemo.vue'

const routes = [
  { path: '/', component: RouteLeaveGuardDemo },
  { path: '/about', component: { template: '<div>About</div>' } },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

const App = { template: '<router-view />' }

describe('RouteLeaveGuardDemo', () => {
  beforeEach(async () => {
    vi.clearAllMocks()
    window.confirm = vi.fn()
    await router.push('/')
    await router.isReady()
  })

  it('should prompt when guard is triggered and user confirms', async () => {
    // Set window.confirm to simulate a user confirming the prompt
    window.confirm = vi.fn(() => true)

    // Render the component within a router context
    render(App, {
      global: {
        plugins: [router],
      },
    })

    const user = userEvent.setup()

    // Find the 'About' link and simulate a user click
    const aboutLink = screen.getByRole('link', { name: /About/i })
    await user.click(aboutLink)

    // Assert that the confirm dialog was shown with the correct message
    expect(window.confirm).toHaveBeenCalledWith('Do you really want to leave this page?')

    // Verify that the navigation was allowed and the route changed to '/about'
    expect(router.currentRoute.value.path).toBe('/about')
  })
})

Reusable Router Test Helper

Create a helper function to simplify router setup:

// test-utils.ts
import { render } from '@testing-library/vue'
import { createRouter, createWebHistory } from 'vue-router'
import type { RenderOptions } from '@testing-library/vue'
// path of the definition of your routes
import { routes } from '../../router/index.ts'

interface RenderWithRouterOptions extends Omit<RenderOptions<any>, 'global'> {
  initialRoute?: string
  routerOptions?: {
    routes?: typeof routes
    history?: ReturnType<typeof createWebHistory>
  }
}

export function renderWithRouter(Component: any, options: RenderWithRouterOptions = {}) {
  const { initialRoute = '/', routerOptions = {}, ...renderOptions } = options

  const router = createRouter({
    history: createWebHistory(),
    // Use provided routes or import from your router file
    routes: routerOptions.routes || routes,
  })

  router.push(initialRoute)

  return {
    // Return everything from regular render, plus the router instance
    ...render(Component, {
      global: {
        plugins: [router],
      },
      ...renderOptions,
    }),
    router,
  }
}

Use the helper in tests:

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

    await router.isReady()
    const user = userEvent.setup()
    
    await user.click(screen.getByText('Dashboard'))
    expect(router.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.

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now

Most Related Posts