Vuex 是一个专为 Vue.js 应用程序开发的状态管理库,它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
通常来说 Vue 组件会在以下方面和 Vuex 发生交互:
- commit 一个 mutation
- dispatch 一个 action
- 通过
$store.state
或 getters 访问 state
本文将探讨在单元测试中,如何覆盖 Vuex 部分的问题。
store 所执行的任何逻辑,诸如 mutations 和 getters,都能被单独地测试 -- 因为 Vuex stores 由普通 JavaScript 函数组成,所以它们也易于被单元测试;同时这些测试都是基于 Vuex store 的当前 state 来断言组件行为是否正常的,并不需要知道 mutators、actions 或 getters 的实现。
1. 测试 Mutations
创建 mutation
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
创建 action
我们会遵循一个通常的 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
让我们进行下去并编写测试,并让报错信息指引我们。
编写测试
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 test:unit
现在测试通过了!
测试 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 = false jest.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.") } } }
现在测试通过了。
3. 测试 getters
我们考虑一个用两个 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
,并接受一个年龄参数
创建 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
开始吧。
编写测试
鉴于一个 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 的相同技术被测试。