Testing Vue Router: Route Guards, Navigation, and router-view in Component Tests

Testing Vue Router: Route Guards, Navigation, and router-view in Component Tests

Testing Vue Router involves three distinct scenarios: testing route guards in isolation (pure functions), testing components that use router composables (useRoute, useRouter), and testing the full router flow with real navigation. Each requires a different approach. This guide covers all three with Vitest and Vue Test Utils 2.

Key Takeaways

Route guards are just functions — test them without mounting components. A navigation guard receives (to, from, next). Call it directly with mock route objects and assert on next.

Use createMemoryHistory() for in-memory router in tests. Memory history doesn't affect the browser's URL bar — perfect for test environments. Avoid createWebHistory() in tests.

await router.isReady() before making assertions. After mounting a component with a router plugin, wait for the initial navigation to complete before checking the DOM.

Mock useRouter and useRoute when you only need route data. For components that just read the current route or call router.push(), mocking these composables is faster than configuring a full router instance.

Programmatic navigation returns a Promise. Always await router.push(...) in tests. Without await, navigation may be pending when you make assertions.

Testing Route Guards

Route guards are pure functions. Test them directly without Vue Test Utils:

// src/router/guards/auth.guard.ts
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
import { useAuthStore } from '@/stores/auth';

export async function authGuard(
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
): Promise<void> {
  const authStore = useAuthStore();
  
  if (authStore.isAuthenticated) {
    next();
  } else {
    next({ name: 'login', query: { redirect: to.fullPath } });
  }
}
// src/router/guards/auth.guard.test.ts
import { setActivePinia, createPinia } from 'pinia';
import { useAuthStore } from '@/stores/auth';
import { authGuard } from './auth.guard';

const mockTo = { fullPath: '/dashboard', name: 'dashboard' } as any;
const mockFrom = { fullPath: '/' } as any;

describe('authGuard', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it('calls next() when user is authenticated', async () => {
    const authStore = useAuthStore();
    authStore.$patch({ user: { id: '1', name: 'Alice' } });

    const next = vi.fn();
    await authGuard(mockTo, mockFrom, next);

    expect(next).toHaveBeenCalledWith();  // called with no arguments = proceed
  });

  it('redirects to login when not authenticated', async () => {
    const authStore = useAuthStore();
    authStore.$patch({ user: null });

    const next = vi.fn();
    await authGuard(mockTo, mockFrom, next);

    expect(next).toHaveBeenCalledWith({
      name: 'login',
      query: { redirect: '/dashboard' },
    });
  });
});

Testing Role-Based Guards

export function roleGuard(requiredRole: string) {
  return (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
    const authStore = useAuthStore();
    
    if (!authStore.user) {
      next({ name: 'login' });
    } else if (authStore.user.role !== requiredRole) {
      next({ name: 'forbidden' });
    } else {
      next();
    }
  };
}
describe('roleGuard', () => {
  it('allows access for correct role', () => {
    const authStore = useAuthStore();
    authStore.$patch({ user: { id: '1', role: 'admin' } });

    const guard = roleGuard('admin');
    const next = vi.fn();
    guard(mockTo, mockFrom, next);

    expect(next).toHaveBeenCalledWith();
  });

  it('redirects to forbidden for wrong role', () => {
    const authStore = useAuthStore();
    authStore.$patch({ user: { id: '1', role: 'viewer' } });

    const guard = roleGuard('admin');
    const next = vi.fn();
    guard(mockTo, mockFrom, next);

    expect(next).toHaveBeenCalledWith({ name: 'forbidden' });
  });
});

Testing Components with a Real Router

For components that render based on the current route:

import { mount, flushPromises } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import AppNav from '@/components/AppNav.vue';

const routes = [
  { path: '/', component: { template: '<div>Home</div>' } },
  { path: '/users', component: { template: '<div>Users</div>' } },
  { path: '/users/:id', component: { template: '<div>User Detail</div>' } },
];

describe('AppNav', () => {
  async function mountWithRouter(initialRoute = '/') {
    const router = createRouter({
      history: createMemoryHistory(),
      routes,
    });

    await router.push(initialRoute);

    const wrapper = mount(AppNav, {
      global: { plugins: [router] },
    });

    await router.isReady();
    return { wrapper, router };
  }

  it('highlights the active route', async () => {
    const { wrapper } = await mountWithRouter('/users');

    expect(wrapper.get('[data-testid="users-link"]').classes()).toContain('router-link-active');
  });

  it('navigates to users page on link click', async () => {
    const { wrapper, router } = await mountWithRouter('/');

    await wrapper.get('[data-testid="users-link"]').trigger('click');
    await flushPromises();

    expect(router.currentRoute.value.path).toBe('/users');
  });
});

Testing router-view

import { App } from '@/App.vue';

describe('App routing', () => {
  it('renders the correct component for /users', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: [
        { path: '/', component: HomePage },
        { path: '/users', component: UsersPage },
      ],
    });

    await router.push('/users');

    const wrapper = mount(App, {
      global: { plugins: [router] },
    });

    await router.isReady();

    expect(wrapper.findComponent(UsersPage).exists()).toBe(true);
    expect(wrapper.findComponent(HomePage).exists()).toBe(false);
  });
});

Mocking useRouter and useRoute

When a component just reads route params or calls router.push(), mocking the composables is faster:

import { vi } from 'vitest';
import { ref } from 'vue';

const mockPush = vi.fn();
const mockRoute = {
  params: ref({ id: '123' }),
  query: ref({ tab: 'overview' }),
  name: ref('user-detail'),
};

vi.mock('vue-router', () => ({
  useRouter: () => ({ push: mockPush }),
  useRoute: () => mockRoute,
}));

describe('UserDetailView', () => {
  it('loads user with the id from route params', async () => {
    const wrapper = mount(UserDetailView);
    await flushPromises();

    expect(wrapper.text()).toContain('User 123');
  });

  it('navigates back to users list on back button', async () => {
    const wrapper = mount(UserDetailView);

    await wrapper.get('[data-testid="back-btn"]').trigger('click');

    expect(mockPush).toHaveBeenCalledWith({ name: 'users' });
  });
});

Testing Navigation Guards in the Router

Test that guards are registered and fire correctly during navigation:

it('redirects unauthenticated users away from /dashboard', async () => {
  const router = createRouter({
    history: createMemoryHistory(),
    routes: [
      { path: '/dashboard', component: Dashboard, beforeEnter: authGuard },
      { path: '/login', component: Login },
    ],
  });

  // Not authenticated
  const authStore = useAuthStore();
  authStore.$patch({ user: null });

  await router.push('/dashboard');

  expect(router.currentRoute.value.name).toBe('login');
  expect(router.currentRoute.value.query.redirect).toBe('/dashboard');
});

Testing Lazy-Loaded Routes

const routes = [
  {
    path: '/heavy',
    component: () => import('@/views/HeavyView.vue'),  // lazy-loaded
  },
];

it('loads the lazy component on navigation', async () => {
  const router = createRouter({
    history: createMemoryHistory(),
    routes,
  });

  const wrapper = mount(App, {
    global: { plugins: [router] },
  });

  await router.push('/heavy');
  await flushPromises();  // waits for the dynamic import to resolve

  expect(wrapper.findComponent({ name: 'HeavyView' }).exists()).toBe(true);
});

Running Router Tests

npx vitest run src/router/             # guard tests only
npx vitest run src/views/              <span class="hljs-comment"># view tests only
npx vitest --reporter=verbose          <span class="hljs-comment"># see each test name

Production Routing

Router tests verify navigation logic in isolation. They don't test that your real application routes correctly in a browser with actual authentication, session cookies, and server-side redirects.

HelpMeTest tests your deployed application's routing by navigating like a real user — logging in, traversing routes, and asserting on page content. Start free with 10 tests.

Read more