测试Vuex

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-02-26

了解 Vuex

state 是存储在当前运行应用程序中的数据,刷新页面将失去 state。当两个组件对相同数据具有各自的 state 时,可能一个组件中的 state 的更改不会更改另一个组件中相关 state 的值,这样就可能造成两个组件对相同数据展示的不一致。

Vuex 就是应用程序存储用于渲染所需 state 的一个中心源,在 Vue 应用程序中,组件从一个 Vuex store 中获取数据,当组件需要更新 state 时,它们会对 store state 进行更改,这将导致依赖于该数据的所有组件需要用新数据重新渲染,通过使用 Vuex store ,你可以避免组件之间数据不同步的问题。

每一个 Vuex 应用程序的核心就是 store,Vuex遵循的核心概念就是单向数据流,单向数据流意味着数据只能在单一方向上流动。它的好处是可以更轻松地追踪到应用程序中的数据来自何处,它简化了应用程序生命周期,避免了组件与 state 之间的复杂的关系。

创建一个 store

import Vuex from 'vuex';

const store = new Vuex.store({
  state: {
    count: 0
  }
})

new Vue({
  store,
  // ...
})

了解 Vuex mutation

在 Vuex 模式中,组件永远不应该直接写入 state,而是应该通过 commit 提交 mutation 来更新 state。

const store = new Vuex.store({
  state: {
    count: 0
  },
  mutations: {
    increment(state) {
      state.count++;
    }
  }
})

触发更新:

<<template>
  <div>
    {{ $store.state.count }}
    <button @click="$store.commit('increment')">增加</button>
  </div>
</template>>

在 Vuex 中 mutation 是改变 store state 的唯一方法。

了解 Vuex action

为确保 mutaition 在 Vue 开发工具中可跟踪,mutation 必须是同步的,如果要异步编辑 state,你可以使用 action。

假设你需要进行一个 AJAX 调用来获取数据,并用过 commit 将数据提交到 Vuex store:

const store = new Vuex.store({
  // ....
  actions: {
    fetchItems(context) {
      fetch('/api').then(data => {
        context.commit('fetchItemData', data.json())
      })
    }
  }
})

然后,你可以使用 dispatch 方法在组件中分发该 action,dispatch 类似于 commit 方法,但是它是用于 action 的。

<!-- 在组件中 -->
<script>
export default {
  methods: {
    fetchData() {
      this.$store.dispatch('fetchItems');
    }
  }
}
</script>

action 是异步的,action 可以提交 mutation ,可以访问 store 实例,必须使用 dispatch 函数调用 action。

了解 Vuex getter

Vuex getter 就像是 store 的计算属性,只有当它们所依赖的数据发生变化的时,才会重新计算它们的值。

const store = new Vuex.store({
  // ...
  getters: {
    filterData(state) {
      // 返回可以展示的产品
      return state.productList.filter(product => product.show);
    }
  }
})

getter 只有在它们依赖的数据发生变化时才会被调用,如果数据未变化,它们会返回之前的计算,你可以将它们视为缓存函数。这样如果数据未发生变化,你再次访问的时候就不需要重新计算了。

<template>
  <div>
    <div v-for="product in $store.getters.filterData">
      {{ product.id }}
    </div>
  </div>
</template>

分别测试 Vuex store 的组成部分

测试 mutation

在底层,commit 使用 store state 和可选参数 payload 对象调用一个 mutation。要为一个 mutation 编写单元测试,你可以使用相同的参数调用该 mutation。先创建一个假 state 对象和 payload 对象,然后使用假 state 和 payload 对象调用该 mutation,并断言 state 对象已被变更为正确的值。

import mutations from './mutations';

describe('mutations', () => {
  test('setItems sets state.items to items', () => {
    const items = [{id: 1}, {id: 2}];
    const state = {
      items: []
    }
    mutations.setItems(state, { items });
    expect(state.items).toBe(items);
  })
})

对应的 mutation.js 编写如下:

// mutation.js
export default {
  setItems(state, { items }) {
    state.items = items;
  }
}

一个 mutation 测试的最终断言始终是要检查一个 state 对象是否被正确更改,因为这是 mutation 的目的,你可以将更改后的 state 对象视为一个 mutation 的输出。

测试 Vuex getter

getter 始终返回一个值,这样使要测试的内容变得简单化,你将始终断言 getter 函数的返回值,要测试 getter,可以使用一个假的 state 对象调用 getter 函数,该 state 对象包含 getter 将使用的值,然后断言 getter 是否返回你期望的结果。

// getters.js
export default {
  inStockProducts: (state) => {
    return state.products.filter(p => p.stock > 0);
  }
}

对应测试用例如下:

import getters from './getters';

describe('getters', () => {
  test('inStockProducts returns products in stock', () => {
    const state = {
      products: [
        { stock: 2},
        { stock: 0},
        { stock: 3},
      ]
    }
    const result = getters.inStockProducts(state);
    expect(result).toHaveLength(2);
  })

  // 另一个例子 数据截取前20条的 getter
  test('displayItems returns the first 20 items from state.item', () => {
    const items = Array(21).fill().map((v, i) => i);
    const state = {
      items,
    }
    const result = getters.displayItems(state);
    const expectedResult = items.slice(0, 20);
    expect(result).toEqual(expectedResult);
  })
})

getter 还可以使用其他 getter 来计算数据,使用之前 getter 的结果称为 链式getter,因为你连接它们的返回值从而获得一个新的值,链式getter的测试与普通getter的测试区别在于它接受一个其他getter的的结果对象作为它们的第二个参数,你可以按照为 getter 编写测试的同样方式,为链式 getter 编写测试————使用一个假的 state 对象和一个假的 getter 对象调用 getter。

测试 Vuex action

通常,action 会进行 API 调用提交结果,因为 action 可以是异步的,并且可以发送HTTP请求。

与 mutation 一样,你不能直接在你的应用中调用 action 函数,而是要使用 store 的 dispatch 方法分发一个 action,与 commit 方法一样,dispatch 有两个参数,一个参数是type,标识调用 action 使用的标识符,第二个参数是 payload。

要测试 action ,你可以按照 Vuex 调用它的方式调用该函数,并断言该 action 是否按你的期望执行。通常,这意味着要利用模拟避免产生 HTTP 调用。

记住永远不要在你的单元测试中进行 HTTP 调用。 HTTP 调用会使单元测试运行时间更长,并且使测试变得不稳定。

当你需要测试 actions 时,你会方向 action 通常调用 API 方法来获取获取,然后 commit mutation,从而将数据保存到 state,这又将导致对应依赖的 getters 重新计算,任何依赖该数据的组件将重新渲染,所以要独立测试 actions,你需要使用极限模拟。

极限模拟是指你在测试中模拟复杂的功能,极限模拟可能会很危险,你使用过的 mock 越多,你的测试就越不准确。 mock 不测试实际功能,它们只是在测试假设的功能。

你模拟的越多,意味着你做出的假设就越多,就越不符合正常的运行时环境。当你的模拟假设不正确时,可能会引入 bug。同时 mock 也会使测试更难以维护和理解,测试也将变得更加昂贵。你需要确保测试编写和维护 mock 所需的额外时间可以被运行单元测试所节省下来的时间平衡。

所以要尽量以较少的模拟来测试 action。

关于下面的 ject.mock('../../api/api') 请参考 使用 jest mock 模拟模块依赖

import actions from '../actions';
import { fetchListData } from '../../api/api';
import flushPromises from 'flush-promises';

jest.mock('../../api/api'); // 对应 __mocks__

describe('actions', () => {
  test('fetchListData calls commit with the result of fetchListData'), async () => {
    expect.assertions(1);
    const items = [{}, {}];
    const type = 'top';

    // 通过 jest.mock('../../api/api') 之后, fetchListData 就是一个 jest mock函数了
    // 就可以调用相关api了 这里mockImplementationOnce改变 fetchListData的行为
    // 这里模拟的是,如果传入的 payload 的 type 正确就返回一个含有项目数组的已解析的 promise
    fetchListData.mockImplementationOnce(calledWith => {
      return calledWith === type ? Promise.resolve(items) : Promise.resolve();
    })

    // 模拟一个假的上下文对象,然后断言 commit mock 函数是通过正确参数被调用的
    const context = {
      commit: jest.fn(),
    }

    // 调用 action的时候, fetchData 就会去调用 fetchListData
    // 这时候的 fetchListData 已经被mock改变了行为
    actions.fetchData(context, { type });

    // 这个时候,下面action 中的then的回调 commit('setItems', { items }); 还没有被调用
    // 因为会在事件循环的下一次循环才被调用
    // 所有这里要 await flushPromises()
    // 它会等待所有的 promise 的回调完成调用
    await flushPromises();
    expect(context.commit).toHaveBeenCalledWith('setItems', { items });
  }
})

对应的 action 代码编写如下:

import { fetchListData } from '../../api';

export default = {
  fetchData({ commit }, { type }) {
    return fetchListData(type).then(items => {
      commit('setItems', { items });
    })
  }
}

对应的 ../../api/api.js 代码如下:

export fetchListData() {
  return fetch('/api'); // 返回一个promise
}

对应 jest.mock('../../api/api'); 的 mock 代码../../api/__mocks__/api.js如下:

export const fetchListData = jest.fn(() => Promise.resolve([]))

测试一个 Vuex store 实例

一个 Vuex store 中的 state 对象是对 store 配置对象中定义的 state 对象的引用。 Vuex store state 的任何更改都将改变 store 配置中的 state。

如果你要编写一个检查 state 中 count 的值,先通过 commit 改变 count的值,那么此后其他的测试访问到的 count 都将是你第一个测试所改变后的值。

单元测试中最不希望看到的就是测试之间的 mutation 泄露。解决方案是通过克隆 store 配置对象删除任何对象引用。这样你就可以继续使用基础的初始的 store对象,并且每次都会有一个全新的 store。

import Vue from 'vue';
import Vuex from 'vuex';

describe('test', () => {
  test('increment updates state.count by 1', () => {
    Vue.use(Vuex);
    const clonedStoreConfig = cloneDeep(storeConfig);
    const store = new Vuex.Store(clonedStoreConfig);
    expect(store.state.count).toBe(0);
    store.commit('increment');
    expect(store.state.count).toBe(1);
  })
})

不要使用 ...Object.assign() 来克隆,因为它们的copy仅限于第一层的基本类型,对引用类型其实还是浅复制。

了解 localVue

localVue 构造函数是保持单元测试隔离和清洁的一种方式,在底层中,Vue Test Utils 使用基础 Vue 构造函数挂载组件。它的问题在于 Vue 基础构造函数的任何变更都会影响使用该构造函数创建的所有实例。这可能导致测试泄露,即前面测试对构造函数的变更会影响将来的测试

因此,你应该不惜一切代价地避免更改 Vue 构造函数,这在原则上是可行的,但是在实际测试中你通常需要安装 Vue 插件 (Vuex, VueRouter),这些插件可能对 Vue 基础构造函数进行更改。

要安装插件并避免污染 Vue 基础构造函数,你可以使用 Vue Test Utils 创建的 localVue 构造函数, localVue 构造函数是一个从 Vue 基础构造函数扩展而来的 Vue 构造函数,你可以在 localVue 构造函数上安装插件,而不会影响到 Vue 基础构造函数。 localVue 构造函数就想原件的复印件,它与原版的内容相同,使用购房时业相同,但你可以对它进行更改而不会影响原件。

使用 localVue 和 克隆配置对象来测试 store 实例:

import Vuex from 'vuex';
import { createLocalVue } from '@vue/test-utils';
import cloneDeep from 'loadsh.clonedeep'; // npm i -D loadsh.clonedeep
import flushPromises from 'flush-promises';
import storeConfig from '../store-config';
import { fetchListData } from '../../api/api';

jest.mock('../../api/api'); // 模拟api

const localVue = createLocalVue();
localVue.use(Vuex);

describe('test', () => {
  test('dispatching fetchListData updates displayItems getter', async () => {
    expert.assertions(1);
    const items = new Array(22).fill().map((item, i) => ({ id: i, name: 'item'}));
    const clonedStoreConfig = cloneDeep(storeConfig);
    const store = new Vuex.Store(clonedStoreConfig);
    
    const type = 'top';
    fetchListData.mockImplementation((calledType) => {
      return calledType === type ? Promise.resolve(items) : Promise.resolve();
    })

    store.dispatch('fetchListData', type);
    await flushPromises();
    // store 实现部分请看上面章节 测试 Vuex getter
    expert(store.getters.displayItems).toEqual(items.slice(0, 20));
  })
})

测试 store 实例的好处是你可以避免模拟 Vuex,并且测试实现不是太具体,你可以重构 store 内部,之哟啊 store 维持它的契约就可以;但是缺点也是不太具体,如果 store 实例的测试失败,则可能很难找出导致失败的代码是哪个部分 getters / mutations / actions, 所以为 Vuex 编写测试没有绝对的方法,无论是分别测试 Vuex store 的组成部分还是测试 Vuex 的实例就需要看场景需要和个人取舍了。

测试组件中的 Vuex

可以使用以下两种方式的其中一种为测试中的一个组件提供一个 Vue store。

第一种:创建一个 mock store 对象,并将其添加到带有 mocks 选项的 Vue 实例中,如果 store 很简单,这种方法会很好用,但一个复杂的 store 会引发模拟 Vuex 功能,需要更多的模拟——极限模拟。

const $store = {
  actions: {
    fetchListData: jest.fn()
  }
}

shallowMount(TestComponent, {
  mocks: { $store }
})

第二种:通过 Vuex 和模拟数据创建一个真实的 store 实例,这种方式更加健壮,因为你不需要重新编写 Vuex 功能。

let store;

const storeOptions = {
  getters: {
    displayItems: jest.fn(),
  },
  actions: {
    displayItems: jest.fn(() => Promise.resolve()),
  }
}

store = new Vuex.Store(storeOptions);

测试一个组件中的 Vuex 代码示例

确保测试因正确原因失败

import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import flushPromises from 'flush-promises';
import ItemList from '../ItemList.vue';
import Item from '../../components/Item.vue';

const localVue = createLocalVue();
localVue.use(Vuex);

describe('ItemList', () => {
  let storeOptions;
  let store;

  beforeEach(() => {
    storeOptions = {
      getters: {
        displayItems: jest.fn(),
      },
      actions: {
        displayItems: jest.fn(),
      }
    }

    store = new Vuex.Store(storeOptions);
  })

  test('测试 displayItems getters, 是否正常获取到数据并渲染', () => {
    const items = [{}, {}, {}];
    storeOptions.getters.displayItems.mockReturnValue(items);
    const wrapper = shallowMount(ItemList, {
      localVue,
      store,
    })

    const Items = wrapper.findAll(Item); // ItemList 中有引入 Item,具体查看下面的ItemList.vue 代码
    expect(Items).toHaveLength(items.length);
    Items.wrappers.foeEach((wrapper, i) => {
      expect(wrapper.vm.item).toBe(items[i]);
    })
  })

  test('测试 $bar 的 start 方法是否正常被调用', () => {
    // ItemList 中调用 this.$bar 所以这里也模拟一下,更多详情查看测试组件方法一章和下面的ItemList.vue 代码
    const $bar = {
      start: () => {},
      finish: () => {},
    }

    shallowMount(ItemList, { mocks: { $bar }, localVue, store })
    expect($bar.start).toHaveBeenCalled();
  })

  test('测试 $bar 的 finish 方法是否正常被调用', () => {
    export.assertions(1);
    const $bar = {
      start: () => {},
      finish: () => {},
    }

    shallowMount(ItemList, { mocks: { $bar }, localVue, store })
    await flushPromises(); // finish 是在异步请求完数据之后调用,所以这里要 await 一下
    expect($bar.finish).toHaveBeenCalled();
  })

  test('测试 Action 是否被正确分发', () => {
    export.assertions(1);
    store.dispatch = jest.fn(() => Promise.resolve());
    shallowMount(ItemList, { localVue, store });
    // 具体 dispatch 实现查看上面章节测试 Vuex Action
    expect(store.dispatch).toHaveBeenCalledWith('fetchListData', { type: 'top'});
  })

  test('测试 Action 抛出一个错误时,$bar 的 fail 方法是否被调用', () => {
    export.assertions(1);
    const $bar = {
      start: () => {},
      fail: () => {},
    }
    storeOptions.actions.fetchListData.mockRejectValue(); // 模拟  action fetchListData 的失败
    shallowMount(ItemList, { mocks: { $bar }, localVue, store });
    await flushPromises();
    expect($bar.fail).toHaveBeenCalled();
  })
})

ItemList.vue 代码如下:

<template>
  <div class="item-list-view">
    <div class="item-list">
      <item
        v-for="item in $store.getters.displayItems"
        :key="item.id"
        :item="item"
      />
    </div>
  </div>
</template>

<script>
import Item from '../components/Item.vue'

export default {
  components: {
    Item
  },
  beforeMount () {
    this.loadItems();
  },
  methods: {
    loadItems () {
      this.$bar.start();
      this.$store.dispatch('fetchListData', { type: 'top '}).then(items => {
        this.$bar.finish();
      }).catch(() => {
        this.$bar.fail();
      })
    }
  }
}
</script>