对 Vue-Router 进行单元测试的方法
由于路由通常会把多个组件牵扯到一起操作,所以一般对其的测试都在 端到端/集成 阶段进行,处于测试金字塔的上层。不过,做一些路由的单元测试还是大有益处的。
对于与路由交互的组件,有两种测试方式:
- 使用一个真正的 router 实例
- mock 掉 $route 和 $router 全局对象
因为大多数 vue 应用用的都是官方的 vue router,所以本文会谈谈这个。
创建组件
我们会弄一个简单的 <app>,包含一个 /nested-child 路由。访问 /nested-child 则渲染一个 <nestedroute> 组件。创建 app.vue 文件,并定义如下的最小化组件:
<template> <div id="app"> <router-view /> </div> </template> <script> export default { name: 'app' } </script>
<nestedroute> 同样迷你:
<template> <div>nested route</div> </template> <script> export default { name: "nestedroute" } </script>
现在定义一个路由:
import nestedroute from "@/components/nestedroute.vue" export default [ { path: "/nested-route", component: nestedroute } ]
在真实的应用中,一般会创建一个 router.js 文件并导入定义好的路由,写出来一般是这样的:
import vue from "vue" import vuerouter from "vue-router" import routes from "./routes.js" vue.use(vuerouter) export default new vuerouter({ routes })
为避免调用 vue.use(...) 污染测试的全局命名空间,我们将会在测试中创建基础的路由;这让我们能在单元测试期间更细粒度的控制应用的状态。
编写测试
先看点代码再说吧。我们来测试 app.vue,所以相应的增加一个 app.spec.js:
import { shallowmount, mount, createlocalvue } from "@vue/test-utils" import app from "@/app.vue" import vuerouter from "vue-router" import nestedroute from "@/components/nestedroute.vue" import routes from "@/routes.js" const localvue = createlocalvue() localvue.use(vuerouter) describe("app", () => { it("renders a child component via routing", () => { const router = new vuerouter({ routes }) const wrapper = mount(app, { localvue, router }) router.push("/nested-route") expect(wrapper.find(nestedroute).exists()).tobe(true) }) })
照例,一开始先把各种模块引入我们的测试;尤其是引入了应用中所需的真实路由。这在某种程度上很理想 -- 若真实路由一旦挂了,单元测试就失败,这样我们就能在部署应用之前修复这类问题。
可以在 <app> 测试中使用一个相同的 localvue,并将其声明在第一个 describe 块之外。而由于要为不同的路由做不同的测试,所以把 router 定义在 it 块里。
另一个要注意的是这里用了 mount 而非 shallowmount。如果用了 shallowmount,则 <router-link> 就会被忽略,不管当前路由是什么,渲染的其实都是一个无用的替身组件。
为使用了 mount 的大型渲染树做些变通
使用 mount 在某些情况下很好,但有时却是不理想的。比如,当渲染整个 <app> 组件时,正赶上渲染树很大,包含了许多组件,一层层的组件又有自己的子组件。这么些个子组件都要触发各种生命周期钩子、发起 api 请求什么的。
如果你在用 jest,其强大的 mock 系统为此提供了一个优雅的解决方法。可以简单的 mock 掉子组件,在本例中也就是 <nestedroute>。使用了下面的写法后,以上测试也将能通过:
jest.mock("@/components/nestedroute.vue", () => ({ name: "nestedroute", render: h => h("div") }))
使用 mock router
有时真实路由也不是必要的。现在升级一下 <nestedroute>,让其根据当前 url 的查询字符串显示一个用户名。这次我们用 tdd 实现这个特性。以下是一个基础测试,简单的渲染了组件并写了一句断言:
import { shallowmount } from "@vue/test-utils" import nestedroute from "@/components/nestedroute.vue" import routes from "@/routes.js" describe("nestedroute", () => { it("renders a username from query string", () => { const username = "alice" const wrapper = shallowmount(nestedroute) expect(wrapper.find(".username").text()).tobe(username) }) })
然而我们并没有 <div class="username"> ,所以一运行测试就会报错:
tests/unit/nestedroute.spec.js
nestedroute
✕ renders a username from query string (25ms)● nestedroute › renders a username from query string
[vue-test-utils]: find did not return .username, cannot call text() on empty wrapper
来更新一下 <nestedroute>:
<template> <div> nested route <div class="username"> {{ $route.params.username }} </div> </div> </template>
现在报错变为了:
tests/unit/nestedroute.spec.js
nestedroute
✕ renders a username from query string (17ms)● nestedroute › renders a username from query string
typeerror: cannot read property 'params' of undefined
这是因为 $route 并不存在。 我们当然可以用一个真正的路由,但在这样的情况下只用一个 mocks 加载选项会更容易些:
it("renders a username from query string", () => { const username = "alice" const wrapper = shallowmount(nestedroute, { mocks: { $route: { params: { username } } } }) expect(wrapper.find(".username").text()).tobe(username) })
这样测试就能通过了。在本例中,我们没有做任何的导航或是和路由的实现相关的任何其他东西,所以 mocks 就挺好。我们并不真的关心 username 是从查询字符串中怎么来的,只要它出现就好。
测试路由钩子的策略
vue router 提供了多种类型的路由钩子, 称为 “navigation guards”。举两个例子如:
- 全局 guards (router.beforeeach)。在 router 实例上声明
- 组件内 guards,比如 beforerouteenter。在组件中声明
要确保这些运作正常,一般是集成测试的工作,因为需要一个使用者从一个理由导航到另一个。但也可以用单元测试检验导航 guards 中调用的函数是否正常工作,并更快的获得潜在错误的反馈。这里列出一些如何从导航 guards 中解耦逻辑的策略,以及为此编写的单元测试。
全局 guards
比方说当路由中包含 shouldbustcache 元数据的情况下,有那么一个 bustcache 函数就应该被调用。路由可能长这样:
//routes.js import nestedroute from "@/components/nestedroute.vue" export default [ { path: "/nested-route", component: nestedroute, meta: { shouldbustcache: true } } ]
之所以使用 shouldbustcache 元数据,是为了让缓存无效,从而确保用户不会取得旧数据。一种可能的实现如下:
//router.js import vue from "vue" import vuerouter from "vue-router" import routes from "./routes.js" import { bustcache } from "./bust-cache.js" vue.use(vuerouter) const router = new vuerouter({ routes }) router.beforeeach((to, from, next) => { if (to.matched.some(record => record.meta.shouldbustcache)) { bustcache() } next() }) export default router
在单元测试中,你可能想导入 router 实例,并试图通过 router.beforehooks[0]() 的写法调用 beforeeach;但这将抛出一个关于 next 的错误 -- 因为没法传入正确的参数。针对这个问题,一种策略是在将 beforeeach 导航钩子耦合到路由中之前,解耦并单独导出它。做法是这样的:
//router.js export function beforeeach((to, from, next) { if (to.matched.some(record => record.meta.shouldbustcache)) { bustcache() } next() } router.beforeeach((to, from, next) => beforeeach(to, from, next)) export default router
再写测试就容易了,虽然写起来有点长:
import { beforeeach } from "@/router.js" import mockmodule from "@/bust-cache.js" jest.mock("@/bust-cache.js", () => ({ bustcache: jest.fn() })) describe("beforeeach", () => { aftereach(() => { mockmodule.bustcache.mockclear() }) it("busts the cache when going to /user", () => { const to = { matched: [{ meta: { shouldbustcache: true } }] } const next = jest.fn() beforeeach(to, undefined, next) expect(mockmodule.bustcache).tohavebeencalled() expect(next).tohavebeencalled() }) it("busts the cache when going to /user", () => { const to = { matched: [{ meta: { shouldbustcache: false } }] } const next = jest.fn() beforeeach(to, undefined, next) expect(mockmodule.bustcache).not.tohavebeencalled() expect(next).tohavebeencalled() }) })
最主要的有趣之处在于,我们借助 jest.mock,mock 掉了整个模块,并用 aftereach 钩子将其复原。通过将 beforeeach 导出为一个已结耦的、普通的 javascript 函数,从而让其在测试中不成问题。
为了确定 hook 真的调用了 bustcache 并且显示了最新的数据,可以使用一个诸如 cypress.io 的端到端测试工具,它也在应用脚手架 vue-cli 的选项中提供了。
组件 guards
一旦将组件 guards 视为已结耦的、普通的 javascript 函数,则它们也是易于测试的。假设我们为 <nestedroute> 添加了一个 beforerouteleave hook:
//nestedroute.vue <script> import { bustcache } from "@/bust-cache.js" export default { name: "nestedroute", beforerouteleave(to, from, next) { bustcache() next() } } </script>
对在全局 guard 中的方法照猫画虎就可以测试它了:
// ... import nestedroute from "@/compoents/nestedroute.vue" import mockmodule from "@/bust-cache.js" jest.mock("@/bust-cache.js", () => ({ bustcache: jest.fn() })) it("calls bustcache and next when leaving the route", () => { const next = jest.fn() nestedroute.beforerouteleave(undefined, undefined, next) expect(mockmodule.bustcache).tohavebeencalled() expect(next).tohavebeencalled() })
这样的单元测试行之有效,可以在开发过程中立即得到反馈;但由于路由和导航 hooks 常与各种组件互相影响以达到某些效果,也应该做一些集成测试以确保所有事情如预期般工作。
总结
本文讲述了:
- 测试由 vue router 条件渲染的组件
- 用 jest.mock 和 localvue 去 mock vue 组件
- 从 router 中解耦全局导航 guard 并对其独立测试
- 用 jest.mock 来 mock 一个模块
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。