为什么要 Mock Vue Router?
p>在 Vue 项目做单元测试时,碰到路由相关逻辑总有点头疼?比如组件里用了 $router.push、依赖 $route.params,或者要测试导航守卫…这时候用 Jest mock Vue Router 就能解决依赖隔离和行为断言的问题,下面从「为什么要 mock」「怎么 mock 基础场景」到「复杂场景处理」一步步讲清楚~
先想明白测试的「隔离性」——单元测试要聚焦被测逻辑,少依赖外部环境,真实 Vue Router 有这些麻烦:- 环境依赖:createWebHistory 依赖浏览器的 history API,测试环境(Node.js)里跑会报错;
- 副作用干扰:路由跳转真的会改变页面状态,测试用例之间容易互相影响;
- 逻辑不可控:比如要测试「点击按钮跳转到 /about」,总不能真的让路由跳转后再断言吧?mock 能让路由方法变成可监听的「桩函数」,直接看有没有被调用、传啥参数。
简单说,mock 后能把路由变成「自己人」,想让它咋表现就咋表现,测试逻辑更干净稳定。
基础场景:组件里用 $router/$route 咋 Mock?
不管是 Vue2 还是 Vue3,核心思路是「把组件里依赖的 $router/$route 换成 mock 对象」,分场景看:
Vue2 项目(选项式 API 为主)
组件里通过 this.$router.push
跳转?测试时给 Vue 原型打补丁就行,举个例子,有个按钮点击后跳转到 /detail
:
<!-- MyButton.vue --> <template><button @click="$router.push('/detail')">跳转</button></template> <script> export default { name: 'MyButton' } </script>
测试文件里这么写:
import Vue from 'vue' import { shallowMount } from '@vue/test-utils' import MyButton from '@/components/MyButton.vue' // 先 mock 掉 vue-router 模块(可选,若不需要真实模块逻辑) jest.mock('vue-router') describe('MyButton 路由跳转测试', () => { beforeEach(() => { // 每次测试前重置 $router,避免用例互相影响 Vue.prototype.$router = { push: jest.fn() // 把 push 换成 jest 函数,方便断言 } }) it('点击按钮触发 $router.push', () => { const wrapper = shallowMount(MyButton) wrapper.find('button').trigger('click') // 断言 push 被调用,且参数是 /detail expect(Vue.prototype.$router.push).toHaveBeenCalledWith('/detail') }) })
原理:Vue2 里路由实例是挂在 Vue.prototype
上的,所以给原型的 $router
赋值 mock 对象,组件里的 this.$router
就会指向它。
Vue3 项目(组合式 API 为主)
Vue3 用 app.config.globalProperties
挂载全局属性,测试时要把 mock 的路由挂到 app 实例上,比如组件用组合式 API,通过 useRouter()
拿路由:
<!-- MyButton.vue --> <template><button @click="handleClick">跳转</button></template> <script setup> import { useRouter } from 'vue-router' const router = useRouter() const handleClick = () => router.push('/detail') </script>
测试文件这么写:
import { createApp } from 'vue' import { mount } from '@vue/test-utils' import MyButton from '@/components/MyButton.vue' jest.mock('vue-router') // mock 整个 vue-router 模块 describe('MyButton 组合式 API 路由测试', () => { let app // 保存 app 实例,方便复用 beforeEach(() => { app = createApp({}) // 给全局属性注入 mock 的 $router app.config.globalProperties.$router = { push: jest.fn() } }) it('点击按钮触发 router.push', () => { const wrapper = mount(MyButton, { global: { app } // 挂载时传入 app 实例 }) wrapper.find('button').trigger('click') // 断言 push 被调用 expect(app.config.globalProperties.$router.push).toHaveBeenCalledWith('/detail') }) })
要是组件里用 useRouter()
,还得 mock useRouter
方法返回 mock 对象:
jest.mock('vue-router', () => { const original = jest.requireActual('vue-router') return { ...original, useRouter: jest.fn(() => ({ push: jest.fn() })) } }) // 测试用例里直接断言 useRouter 返回的 router.push import { useRouter } from 'vue-router' it('useRouter 触发跳转', () => { const router = useRouter() // ...触发组件逻辑后... expect(router.push).toHaveBeenCalled() })
导航守卫咋 Mock?
导航守卫分「全局守卫(如 router.beforeEach)」和「组件内守卫(如 beforeRouteEnter)」,mock 思路是「模拟守卫的参数,手动触发守卫函数」。
全局前置守卫(router.beforeEach)测试
假设路由配置里有个全局守卫,判断用户是否登录,没登录就跳转到 /login:
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [...] }) router.beforeEach((to, from, next) => { if (!isLogin() && to.path !== '/login') { next('/login') } else { next() } }) export default router
测试这个守卫,要 mock to
from
next
,然后调用守卫函数:
import router from '@/router' jest.mock('vue-router', () => { const original = jest.requireActual('vue-router') return { ...original, createRouter: jest.fn(() => ({ beforeEach: jest.fn(), // 让 router.beforeEach 能被 mock // 其他路由方法按需 mock })), createWebHistory: jest.fn() } }) describe('全局前置守卫测试', () => { it('未登录访问 /home 跳转到 /login', () => { // mock isLogin 始终返回 false(模拟未登录) jest.spyOn(utils, 'isLogin').mockReturnValue(false) // 模拟 to 和 from 对象 const to = { path: '/home' } const from = { path: '/' } const next = jest.fn() // mock next 函数 // 调用守卫(router.beforeEach 里注册的回调,取第一个执行) router.beforeEach.mock.calls[0][0](to, from, next) // 断言 next 被调用时参数是 /login expect(next).toHaveBeenCalledWith('/login') }) })
组件内守卫(beforeRouteEnter)测试
组件里用 beforeRouteEnter
做进入前逻辑,比如预加载数据:
<!-- User.vue --> <template><div>{{ userInfo.name }}</div></template> <script> export default { data() { return { userInfo: {} } }, beforeRouteEnter(to, from, next) { fetchUser(to.params.id).then(res => { next(vm => { vm.userInfo = res.data }) }) } } </script>
测试时要模拟 to
from
next
,还要处理 next
里的回调(给组件实例赋值):
import { mount } from '@vue/test-utils' import User from '@/components/User.vue' jest.mock('@/api', () => ({ fetchUser: jest.fn(() => Promise.resolve({ data: { name: '张三' } })) })) // mock 接口请求 describe('组件内守卫 beforeRouteEnter 测试', () => { it('进入页面时预加载用户数据', async () => { const to = { params: { id: '123' } } const from = { path: '/' } const next = jest.fn() // 调用组件的 beforeRouteEnter 守卫 User.beforeRouteEnter(to, from, next) await Promise.resolve() // 等待 fetchUser 异步完成 // 模拟组件实例 vm,执行 next 的回调 const wrapper = mount(User) next(wrapper.vm) // 把 wrapper.vm 传给 next 的回调 // 断言数据已加载 expect(wrapper.text()).toContain('张三') expect(next).toHaveBeenCalled() }) })
动态路由参数咋 Mock?
组件依赖 $route.params
(比如用户 ID),测试不同参数下的渲染逻辑,关键是「灵活设置 $route 的 params 属性」。
比如组件根据 $route.params.id
渲染用户信息:
<template><div>{{ userId }}</div></template> <script> export default { computed: { userId() { return this.$route.params.id } } } </script>
测试不同 id 的情况:
import Vue from 'vue' import { shallowMount } from '@vue/test-utils' import UserId from '@/components/UserId.vue' describe('动态路由参数测试', () => { beforeEach(() => { // 每次测试前重置 $route,避免污染 Vue.prototype.$route = { params: {} } }) it('参数 id=1 时渲染 1', () => { Vue.prototype.$route.params.id = '1' const wrapper = shallowMount(UserId) expect(wrapper.text()).toContain('1') }) it('参数 id=2 时渲染 2', () => { Vue.prototype.$route.params.id = '2' const wrapper = shallowMount(UserId) expect(wrapper.text()).toContain('2') }) })
要是组件用 watch $route
响应参数变化(比如选项式 API 的 watch),测试时改完 $route
后要触发更新:
<template><div>{{ userId }}</div></template> <script> export default { data() { return { userId: '' } }, watch: { '$route.params.id'(newId) { this.userId = newId } } } </script>
测试代码:
it('watch 路由参数变化更新数据', () => { Vue.prototype.$route = { params: { id: '1' } } const wrapper = shallowMount(UserId) expect(wrapper.vm.userId).toBe('1') // 改变 $route.params.id Vue.prototype.$route.params.id = '2' wrapper.vm.$forceUpdate() // 触发 watch 回调(Vue2 需手动 forceUpdate) expect(wrapper.vm.userId).toBe('2') })
常见坑:Mock 后路由方法没生效?
碰到「明明 mock 了 $router.push,断言时却显示没调用」,大概率是这几个原因:
- Mock 时机不对:Vue3 项目里,mount 组件时没把带 mock 路由的 app 实例传进去,要检查
mount
选项里的global.app
是否正确。 - Mock 对象结构错了:比如把
$router.push
写成对象而不是函数,或者$route
少了params
这类关键属性。 - 测试用例污染:多个用例共享同一个 mock 对象,前一个用例的调用痕迹影响了后一个,解决方法是在
beforeEach
里重置 mock 对象。
进阶:Vue Router 4 + 组合式 API 咋 Mock?
Vue Router 4 用 createRouter
createWebHistory
初始化路由,测试时要 mock 这些工厂函数,返回假的路由实例。
比如项目里路由配置:
// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [...] const router = createRouter({ history: createWebHistory(), routes }) export default router
测试全局守卫前,mock createRouter
和 createWebHistory
:
jest.mock('vue-router', () => { const original = jest.requireActual('vue-router') return { ...original, createRouter: jest.fn(() => ({ beforeEach: jest.fn(), push: jest.fn(), // 其他需要的路由方法 })), createWebHistory: jest.fn(() => ({})), // .mockResolvedValue 之类的按需写 useRouter: jest.fn(() => ({ push: jest.fn() })) } }) // 测试用例里就能自由控制路由行为 import router from '@/router' it('测试 createRouter 返回的实例', () => { expect(createRouter).toHaveBeenCalled() router.beforeEach((to, from, next) => { ... }) // 调用守卫逻辑 })
Jest mock Vue Router 核心是「用假对象替代真实路由,把路由方法变成可断言的桩函数」,不管是基础的 $router/$route 模拟,还是导航守卫、动态路由这些复杂场景,只要记住「隔离依赖 + 控制输入输出」,测试逻辑就会清晰又稳定~要是你在测试时碰到其他路由相关问题,评论区聊聊?
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。