27

[译] Vuex 之单元测试

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI0MDYzOTEyOA%3D%3D&%3Bmid=2247484301&%3Bidx=1&%3Bsn=063f0e6d695d42a4a0efb1932af94f6d
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ieMjaii.png!web

原文:https://lmiller1990.github.io/vue-testing-handbook/testing-vuex.html

通常来说 Vue 组件会在以下方面和 Vuex 发生交互:

  1. commit 一个 mutation

  2. dispatch 一个 action

  3. 通过 $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:

  1. 发起一个向 API 的异步请求

  2. 对数据进行一些处理(可选)

  3. 根据 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 的测试应该断言:

  1. 是否使用了正确的 API 端?

  2. payload 是否正确?

  3. 根据结果,是否有正确的 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 = 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.")
    }
  }
}

现在测试通过了。

2.3 - 改良

现在你知道如何单独地测试 actions 了。至少还有一项潜在的改进可以为之,那就是将 axios mock 实现为一个   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 我们将测试:

  1. poodles : 取得所有   poodles

  2. 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 。请记住,我们要验证两件事:

  1. 正确的 mutation 是否被 commit 了?

  2. 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

让我们来看看代码,然后和前面的测试类比、对比一下。请记住,我们要验证:

  1. 正确的 action 被 dispatch 了

  2. 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.fn mock,并对它做了断言。

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

--End--

Fjuauei.jpg!web

搜索 fewelife 关注公众号

转载请注明出处


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK