
原文:https://lmiller1990.github.io/vue-testing-handbook/testing-vuex.html
通常来说 Vue 组件会在以下方面和 Vuex 发生交互:
-
commit 一个 mutation
-
dispatch 一个 action
-
通过
$store.state或 getters 访问 state
要针对 Vuex 进行的单元测试,都是基于 Vuex store 的当前 state 来断言组件行为是否正常的;并不需要知道 mutators、actions 或 getters 的具体实现。
1 - 测试 Mutations
由于 mutations 就是普通的 JavaScript 函数,所以单独地测试它们非常容易。
mutations 一般遵循一套模式:取得一些数据,可能进行一些处理,然后将数据赋值给 state。
比如一个 ADD_POST mutation 的概述如下:一旦被实现,它将从 payload 中获取一个 post 对象,并将 post.id 添加到 state.postIds 中;它也会将那个 post 对象以 post.id 为 key 添加到 state.posts 对象中。这即是在应用中使用 Vuex 的一个通常的模式。
我们将使用 TDD 进行开发。mutation 是这样开头的:
export default { SET_POST(state, { post }) { }}
开始写测试,并让报错信息指引我们的开发:
import mutations from "@/store/mutations.js"describe("SET_POST", () => { it("adds a post to the state", () => { const post = { id: 1, title: "Post" } const state = { postIds: [], posts: {} } mutations.SET_POST(state, { post }) expect(state).toEqual({ postIds: [1], posts: { "1": post } }) })})
以 yarn test:unit 运行测试将产生以下错误信息:
FAIL tests/unit/mutations.spec.js● SET_POST › adds a post to the state expect(received).toEqual(expected) Expected value to equal: {"postIds": [1], "posts": {"1": {"id": 1, "title": "Post"}}} Received: {"postIds": [], "posts": {}}
让我们从将 post.id 加入 state.postIds 开始:
export default { SET_POST(state, { post }) { state.postIds.push(post.id) }}
现在 yarn test:unit 会产生:
Expected value to equal: {"postIds": [1], "posts": {"1": {"id": 1, "title": "Post"}}}Received: {"postIds": [1], "posts": {}}
postIds 看起来挺好了。现在我们只需要将 post 加入 state.posts。限于 Vue 反应式系统的工作方式我们无法简单地写成 post[post.id] = post 来添加 post。基本上,你需要使用 Object.assign 或 ... 操作符创建一个新的对象。此处我们将使用 ... 操作符将 post 赋值到 state.posts:
export default { SET_POST(state, { post }) { state.postIds.push(post.id) state.posts = { ...state.posts, [post.id]: post } }}
测试通过!
2 - 测试 actions
单独地测试 actions 是非常容易的。这和单独地测试 mutations 非常之相似。
同样的,我们会遵循一个通常的 Vuex 模式创建一个 action:
-
发起一个向 API 的异步请求
-
对数据进行一些处理(可选)
-
根据 payload 的结果 commit 一个 mutation
这里有一个 认证 action,用来将 username 和 password 发送到外部 API 以检查它们是否匹配。然后其认证结果将被用于通过 commit 一个 SET_AUTHENTICATED mutation 来更新 state,该 mutation 将认证结果作为 payload。
import axios from "axios"export default { async authenticate({ commit }, { username, password }) { const authenticated = await axios.post("/api/authenticate", { username, password }) commit("set_authenticated", authenticated) }}
action 的测试应该断言:
-
是否使用了正确的 API 端?
-
payload 是否正确?
-
根据结果,是否有正确的 mutation 被 commit
让我们进行下去并编写测试,并让报错信息指引我们。
2.1 - 编写测试
describe("authenticate", () => { it("authenticated a user", async () => { const commit = jest.fn() const username = "alice" const password = "password" await actions.authenticate({ commit }, { username, password }) expect(url).toBe("/api/authenticate") expect(body).toEqual({ username, password }) expect(commit).toHaveBeenCalledWith( "SET_AUTHENTICATED", true) })})
因为 axios 是异步的,为保证 Jest 等到测试完成后才执行,我们需要将其声明为 async 并在其后 await 那个 actions.authenticate 的调用。不然的话(译注:即假如不使用 async/await 而仅仅将 3 个 expect 断言放入异步函数的 then() 中)测试会早于 expect断言完成,并且我们将得到一个常绿的 -- 一个不会失败的测试。
运行以上测试会给我们下面的报错信息:
FAIL tests/unit/actions.spec.js ● authenticate › authenticated a user SyntaxError: The string did not match the expected pattern. at XMLHttpRequest.open (node_modules/jsdom/lib/jsdom/living/xmlhttprequest.js:482:15) at dispatchXhrRequest (node_modules/axios/lib/adapters/xhr.js:45:13) at xhrAdapter (node_modules/axios/lib/adapters/xhr.js:12:10) at dispatchRequest (node_modules/axios/lib/core/dispatchRequest.js:59:10)
这个错误来自 axios 的某处。我们发起了一个对 /api... 的请求,并且因为我们运行在一个测试环境中,所以并不是真有一个服务器在处理请求,这就导致了错误。我们也没有定义 url 或 body -- 我们将在解决掉 axios 错误后做那些。
因为使用了 Jest,我们可以用 jest.mock 容易地 mock 掉 API 调用。我们将用一个 mock 版本的 axios 代替真实的,使我们能更多地控制其行为。Jest 提供了 ES6 Class Mocks,非常适于 mock axios。
axios 的 mock 看起来是这样的:
let url = ''let body = {}jest.mock("axios", () => ({ post: (_url, _body) => { return new Promise((resolve) => { url = _url body = _body resolve(true) }) }}))
我们将 url 和 body 保存到了变量中以便断言正确的时间端点接收了正确的 payload。因为我们不想实现真正的端点,用一个理解 resolve 的 promise 模拟一次成功的 API 调用就够了。
yarn unit:pass 现在测试通过了!
2.2 - 测试 API Error
咱仅仅测试过了 API 调用成功的情况,而测试所有产出的可能情况也是重要的。让我们编写一个测试应对发生错误的情况。这次,我们将先编写测试,再补全实现。
测试可以写成这样:
it("catches an error", async () => { mockError = true await expect(actions.authenticate({ commit: jest.fn() }, {})) .rejects.toThrow("API Error occurred.")})
我们要找到一种强制 axios mock 抛出错误的方法。正如 mockError 变量代表的那样。将 axios mock 更新为:
let url = ''let body = {}let mockError = falsejest.mock("axios", () => ({ post: (_url, _body) => { return new Promise((resolve) => { if (mockError) throw Error() url = _url body = _body resolve(true) }) }}))
只有当一个 ES6 类 mock 作用域外的(out-of-scope)变量以 mock 为前缀时,Jest 才允许访问它。现在我们简单地赋值 mockError = true 然后 axios 就会抛出错误了。
运行该测试给我们这些报错:
FAIL tests/unit/actions.spec.js● authenticate › catchs an error expect(function).toThrow(string) Expected the function to throw an error matching: "API Error occurred." Instead, it threw: Mock error
成功的抛出了一个错误... 却并非我们期望的那个。更新 authenticate 以达到目的:
export default { async authenticate({ commit }, { username, password }) { try { const authenticated = await axios.post("/api/authenticate", { username, password }) commit("SET_AUTHENTICATED", authenticated) } catch (e) { throw Error("API Error occurred.") } }}
现在测试通过了。
2.3 - 改良
现在你知道如何单独地测试 actions 了。至少还有一项潜在的改进可以为之,那就是将 axiosmock 实现为一个 manual mock(https://jestjs.io/docs/en/manual-mocks)。这包含在 node_modules 的同级创建一个 __mocks__ 目录并在其中实现 mock
模块。Jest 将自动使用 __mocks__ 中的 mock 实现。在 Jest 站点和因特网上有大量如何做的例子。
3 - 测试 getters
getters 也是普通的 JavaScript 函数,所以单独地测试它们同样非常容易;所用技术类似于测试 mutations 或 actions。
我们考虑一个用两个 getters 操作一个 store 的案例,看起来是这样的:
const state = { dogs: [ { name: "lucky", breed: "poodle", age: 1 }, { name: "pochy", breed: "dalmatian", age: 2 }, { name: "blackie", breed: "poodle", age: 4 } ]}
对于 getters 我们将测试:
-
poodles: 取得所有poodles -
poodlesByAge: 取得所有poodles,并接受一个年龄参数
3.1 - 创建 getters
首先,创建 getters。
export default { poodles: (state) => { return state.dogs.filter(dog => dog.breed === "poodle") }, poodlesByAge: (state, getters) => (age) => { return getters.poodles.filter(dog => dog.age === age) }}
并没有什么特别令人兴奋的 -- 记住 getter 可以接受其他的 getters 作为第二个参数。因为我们已经有一个 poodles getter 了,可以在 poodlesByAge 中复用它。通过在 poodlesByAge 返回一个接受参数的函数,我们可以向 getters 中传入参数。poodlesByAge getter
用法是这样的:
computed: { puppies() { return this.$store.getters.poodlesByAge(1) }}
让我们从测试 poodles 开始吧。
3.2 - 编写测试
鉴于一个 getter 只是一个接收一个 state 对象作为首个参数的 JavaScript 函数,所以测试起来非常简单。我将把测试写在 getters.spec.js 文件中,代码如下:
import getters from "../../src/store/getters.js"const dogs = [ { name: "lucky", breed: "poodle", age: 1 }, { name: "pochy", breed: "dalmatian", age: 2 }, { name: "blackie", breed: "poodle", age: 4 }]const state = { dogs }describe("poodles", () => { it("returns poodles", () => { const actual = getters.poodles(state) expect(actual).toEqual([ dogs[0], dogs[2] ]) })})
Vuex 会自动将 state 传入 getter。因为我们是单独地测试 getters,所以还得手动传入 state。除此之外,我们就是在测试一个普通的 JavaScript 函数。
poodlesByAge 则更有趣一点了。传入一个 getter 的第二个参数是其他 getters。我们正在测试的是 poodlesByAge,所以我们不想将 poodles 的实现牵扯进来。我们通过 stub 掉 getters.poodles 取而代之。这将给我们对测试更细粒度的控制。
describe("poodlesByAge", () => { it("returns poodles by age", () => { const poodles = [ dogs[0], dogs[2] ] const actual = getters.poodlesByAge(state, { poodles })(1) expect(actual).toEqual([ dogs[0] ]) })})
不同于向 getter 传入真实的 poodles(译注:刚刚测试过的另一个 getter),我们传入的是一个它可能返回的结果。因为之前写过一个测试了,所以我们知道它是工作正常的。这使得我们把测试逻辑单独聚焦于 poodlesByAge。
async 的 getters 也是可能的。它们可以通过和测试 async actions 的相同技术被测试。
4 - 测试组件内的 Vuex:state 和 getters
现在来看看 Vuex 在实际组件中的表现。
4.1 - 使用 createLocalVue 测试 $store.state
在一个普通的 Vue 应用中,我们使用 Vue.use(Vuex) 来安装 Vuex 插件,并将一个新的 Vuex store 传入 app 中。如果我们也在一个单元测试中做同样的事,那么,所有单元测试都得接收那个 Vuex store,尽管测试中根本用不到它。vue-test-utils 提供了一个 createLocalVue 方法,用来为测试提供一个临时 Vue 实例。让我们看看如何使用它。首先,是一个基于 store 的 state 渲染出一个 username 的 <ComponentWithGetters> 组件。
<template> <div> <div class="username"> {{ username }} </div> </div></template><script>export default { name: "ComponentWithVuex", data() { return { username: this.$store.state.username } }}</script>
我们可以使用 createLocalVue 创建一个临时的 Vue 实例,并用其安装 Vuex。而后我们将一个新的 store 传入组件的加载选项中。完整的测试看起来是这样的:
import Vuex from "vuex"import { shallowMount, createLocalVue } from "@vue/test-utils"import ComponentWithVuex from "@/components/ComponentWithVuex.vue"const localVue = createLocalVue()localVue.use(Vuex)const store = new Vuex.Store({ state: { username: "alice" }})describe("ComponentWithVuex", () => { it("renders a username using a real Vuex store", () => { const wrapper = shallowMount(ComponentWithVuex, { store, localVue }) expect(wrapper.find(".username").text()).toBe("alice") })})
测试通过。创建一个新的 localVue 实例引入了一些样板文件(boilerplate),并且测试也很长。如果你有好多使用了 Vuex store 的组件要测试,一个替代方法是使用 mocks 加载选项,用以简化 store 的 mock。
4.2 - 使用一个 mock 的 store
通过使用 mocks 加载选项,可以 mock 掉全局的 $store 对象。这意味着你不需要使用 createLocalVue,或创建一个新的 Vuex store 了。使用此项技术,以上测试可以重写成这样:
it("renders a username using a mock store", () => { const wrapper = shallowMount(ComponentWithVuex, { mocks: { $store: { state: { username: "alice" } } } }) expect(wrapper.find(".username").text()).toBe("alice")})
我个人更喜欢这种实现。所有必须的数据被声明在测试内部,同时它也更紧凑一点儿。当然两种技术都很有用,并没有哪种更好哪种更差之分。
4.3 - 测试 getters
使用上述技术,getters 同样易于测试。首先,是用于测试的组件:
<template> <div class="fullname"> {{ fullname }} </div></template><script>export default { name: "ComponentWithGetters", computed: { fullname() { return this.$store.getters.fullname } }}</script>
我们想要断言组件正确地渲染了用户的 fullname。对于该测试,我们不关心 fullname 来自何方,组件渲染正常就行。
先看看用真实的 Vuex store 和 createLocalVue,测试看起来是这样的:
const localVue = createLocalVue()localVue.use(Vuex)const store = new Vuex.Store({ state: { firstName: "Alice", lastName: "Doe" }, getters: { fullname: (state) => state.firstName + " " + state.lastName }})it("renders a username using a real Vuex getter", () => { const wrapper = shallowMount(ComponentWithGetters, { store, localVue }) expect(wrapper.find(".fullname").text()).toBe("Alice Doe")})
测试很紧凑 -- 只有两行代码。不过也引入了很多设置代码 -- 我们基本上重建了 Vuex store。一个替代方法是引入有着真正 getters 的真实的 Vuex store。这将引入测试中的另一项依赖,当开发一个大系统时,Vuex store 可能由另一位程序员开发,也可能尚未实现。
让我看看使用 mocks 加载选项编写测试的情况:
it("renders a username using computed mounting options", () => { const wrapper = shallowMount(ComponentWithGetters, { mocks: { $store: { getters: { fullname: "Alice Doe" } } } }) expect(wrapper.find(".fullname").text()).toBe("Alice Doe")})
现在全部所需的数据都包含在测试中了。太棒了!我特喜欢这个,因为测试是全包含的(fully contained),理解组件应该做什么所需的所有知识都都包含在测试中。
使用 computed 加载选项,我们甚至能让测试变得更简单。
4.4 - 用 computed 来模拟 getters
getters 通常被包裹在 computed 属性中。请记住,这个测试就是为了在给定 store 中的当前 state 时,确保组件行为的正确性。我们不测试 fullname 的实现或是要瞧瞧 getters 是否工作。这意味着我们可以简单地替换掉真实 store,或使用 computed 加载选项
mock 掉 store。测试可以重写为:
it("renders a username using computed mounting options", () => { const wrapper = shallowMount(ComponentWithGetters, { computed: { fullname: () => "Alice Doe" } }) expect(wrapper.find(".fullname").text()).toBe("Alice Doe")})
这比之前两个测试更简洁了,并且仍然表达了组件的意图。
4.5 - mapState 和 mapGetters 辅助选项
上述技术都能与 Vuex 的 mapState 和 mapGetters 辅助选项结合起来工作。我们可以将 ComponentWithGetters 更新为:
import { mapGetters } from "vuex"export default { name: "ComponentWithGetters", computed: { ...mapGetters([ 'fullname' ]) }}
测试仍然通过。
5 - 测试组件内的 Vuex:mutations 和 actions
刚刚讨论过测试使用了 $store.state 和 $store.getters 的组件,这两者都用来将当前状态提供给组件。而当断言一个组件正确 commit 了一个 mutation 或 dispatch 了一个 action 时,我们真正想做的是断言 $store.commit 和 $store.dispatch 以正确的处理函数(要调用的 mutation 或 action)和 payload 被调用了。
要做到这个也有两种刚才提及的方式。一种是籍由 createLocalVue 使用一个真正的 Vuex store,另一种是使用一个 mock store。让我们再次审视它们,这次是在 mutations 和 actions 的语境中。
5. 1 - 创建组件
在这些例子里,我们将测试一个 <ComponentWithButtons> 组件:
<template> <div> <button class="commit" @click="handleCommit"> Commit </button> <button class="dispatch" @click="handleDispatch"> Dispatch </button> <button class="namespaced-dispatch" @click="handleNamespacedDispatch"> Namespaced Dispatch </button> </div></template><script>export default { name: "ComponentWithButtons", methods: { handleCommit() { this.$store.commit("testMutation", { msg: "Test Commit" }) }, handleDispatch() { this.$store.dispatch("testAction", { msg: "Test Dispatch" }) }, handleNamespacedDispatch() { this.$store.dispatch("namespaced/very/deeply/testAction", { msg: "Test Namespaced Dispatch" }) } }}</script>
5.2 - 用一个真正的 Vuex store 测试 mutation
让我们先来编写一个测试 mutation 的 ComponentWithButtons.spec.js。请记住,我们要验证两件事:
-
正确的 mutation 是否被 commit 了?
-
payload 正确吗?
我们将使用 createLocalVue 以避免污染全局 Vue 实例。
import Vuex from "vuex"import { createLocalVue, shallowMount } from "@vue/test-utils"import ComponentWithButtons from "@/components/ComponentWithButtons.vue"const localVue = createLocalVue()localVue.use(Vuex)const mutations = { testMutation: jest.fn()}const store = new Vuex.Store({ mutations })describe("ComponentWithButtons", () => { it("commits a mutation when a button is clicked", async () => { const wrapper = shallowMount(ComponentWithButtons, { store, localVue }) wrapper.find(".commit").trigger("click") await wrapper.vm.$nextTick() expect(mutations.testMutation).toHaveBeenCalledWith( {}, { msg: "Test Commit" } ) })})
注意测试被标记为 await 并调用了 nextTick。
上面的测试中有很多代码 -- 尽管并没有什么让人兴奋的事情发生。我们创建了一个 localVue 并 use 了 Vuex,然后创建了一个 store,传入一个 Jest mock 函数 (jest.fn()) 代替 testMutation。Vuex mutations 总是以两个参数的形式被调用:第一个参数是当前 state,第二个参数是 payload。因为我们并没有为 store
声明任何 state,我们预期它被调用时第一个参数会是一个空对象。第二个参数预期为 { msg: "Test Commit" },也就是硬编码在组件中的那样。
有好多样板代码要去写,但这是个验证组件行为正确性的恰当而有效的方式。另一种替代方法 mock store 需要的代码更少。让我们来看看如何以那种方式编写一个测试并断言 testAction 被 dispatch 了。
5.3 - 用一个 mock store 测试 action
让我们来看看代码,然后和前面的测试类比、对比一下。请记住,我们要验证:
-
正确的 action 被 dispatch 了
-
payload 是正常的
it("dispatches an action when a button is clicked", async () => { const mockStore = { dispatch: jest.fn() } const wrapper = shallowMount(ComponentWithButtons, { mocks: { $store: mockStore } }) wrapper.find(".dispatch").trigger("click") await wrapper.vm.$nextTick() expect(mockStore.dispatch).toHaveBeenCalledWith( "testAction" , { msg: "Test Dispatch" })})
这比前一个例子要紧凑多了。没有 localVue、没有 Vuex -- 不同于在前一个测试中我们用 testMutation: jest.fn() mock 掉了 commit 后会触发的函数,这次我们实际上 mock 了 dispatch 函数本身。因为 $store.dispatch 只是一个普通的 JavaScript 函数,我们有能力做到这点。而后我们断言第一个参数是正确的 action 处理函数名 testAction、第二个参数 payload 也正确。我们不关心实际发生的 -- 那可以被单独地测试。本次测试的目的就是简单地验证单击一个按钮会 dispatch 正确的带 payload 的 action。
使用真实的 store 或 mock store 全凭个人喜好。都是正确的。重要的事情是你在测试组件。
5.4 - 测试一个 Namespaced Action (或 Mutation)
第三个也是最终的例子展示了另一种测试一个 action 是否被以正确的参数 dispatch (或是 mutation 被 commit)的方式。这结合了以上讨论过的两项技术 -- 一个真实的 Vuex store,和一个 mock 的 dispatch 方法。
it("dispatch a namespaced action when button is clicked", async () => { const store = new Vuex.Store() store.dispatch = jest.fn() const wrapper = shallowMount(ComponentWithButtons, { store, localVue }) wrapper.find(".namespaced-dispatch").trigger("click") await wrapper.vm.$nextTick() expect(store.dispatch).toHaveBeenCalledWith( 'namespaced/very/deeply/testAction', { msg: "Test Namespaced Dispatch" } )})
根据我们感兴趣的模块,从创建一个 Vuex store 开始。我在测试内部声明了模块,但在真实 app 中,你可能需要引入组件依赖的模块。其后我们把 dispatch 方法替换为一个 jest.fnmock,并对它做了断言。
6. 总结
-
mutations和getters都只是普通的 JavaScript 函数,它们可以、也应该,被区别于主 Vue 应用而单独地测试 -
当单独地测试
getters时,你需要手动传入 state -
如果一个 getter 使用了其他 getters,你应该用符合期望的返回结果 stub 掉后者。这将给我们对测试更细粒度的控制,并让你聚焦于测试中的 getter
-
测试一个 action 时,可以使用 Jest ES6 class mocks,并应该同时测试其成功和失败的情况
-
可以使用
createLocalVue和真实 Vuex store 测试$store.state和getters -
可以使用
mocks加载选项 mock 掉$store.state和getters等 -
可以使用
computed加载选项以设置 Vuex getter 的期望值 -
可以直接 mock 掉 Vuex 的 API (
dispatch和commit) -
可以通过一个 mock 的
dispatch函数使用一个真实的 Vuex store

搜索 fewelife 关注公众号转载请注明出处