使用工厂函数组织测试

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

了解工厂函数

在测试中使用工厂函数有两个好处:

  • 避免重复代码。
  • 工厂函数为你提供一种可以沿用的模式

如果有多各测试使用相同的选项调用 shallowMount:

import { shaollowMount } from '@vue/test-utils';

function createWrapper() {
  return shaollowMount(TestCompoent, {
    mocks: {
      $bar: {
        start: jest.fn(),
        fail: jest.fn(),
      }
    }
  })
}

const wrapper = createWrapper();

在编写测试时,大多数人并不考虑代码模式,这只适用于小型测试套件,但当测试套件的体积越来越大时,这将变得非常不利。没有一个明确的模式,测试代码将难以维护,乱成一团。

通常在大型代码库中都会出现计划外的模式,起先,开发人员可能想编写一个函数作某一种功能,后来根据业务的解析或扩展,就为该函数扩展了更多的功能,需要传递更多的参数来控制更多的场景、选项。

之前提到过的 beforeEach 钩子,用来在每个测试之前重写公共变量,这种方法避免了在测试重复创建对象,工厂函数是用来避免重复的另一种模式, before each 模式会更改测试运行之前使用到的变量,而工厂函数在每次调用时都会创建新对象。如果处理得当,使用工厂函数模式的测试 会比使用 before each 模式的测试更容易沿用。

了解工厂函数的利弊

使用工厂函数所付出的代价是增加了代码中的抽象内容,这会使得测试让未来的开发人员更加难以理解。当未来的开发者阅读他人编写的测试时,如果不查看函数的内部实现,他们就无法得知工厂函数作了什么,就需要花费额外的时间。

考虑到这一点,测试中的重复代码也不是一无是处,如果一个测试是独立的,没有其他抽象代码,那么未来的开发者会更加容易理解。所以这也需要一种权衡,当你需要抽象代码的时候。

因此上一章节的 beforeEach 代码可以使用工厂函数改写为这样:

function createStore() {
  const defaultStoreConfig = {
    getters: {
      displayItems: jest.fn(),
    },
    actions: {
      fetchListData: jest.fn(() => Promise.reslove())
    }
  }
  return new Vuex.Store(defadefaultStoreConfig);
}

如果 store 始终以相同的方式运行,这种解决办法是一个不错的选择,但是在测试中,你通常需要控制 store 的返回值,同时也有可能需要一个方法来覆盖 defadefaultStoreConfig 的某些值。

这里使用的工厂函数,返回一个新的对象,就不要担心上一章说的引用问题导致测试泄露了。

要想控制 store 的返回值,可以在 createStore 函数外面声明,用传参的方式传递给 createStore:

const items = [{}, {}, {}];
createStore(items);

const fetchListData = jest.fn();
createStore([], fetchListData);

当时上面的代码,依然有问题,就是当需要“变化”的选项越来越多,参数就会越来越多,这也就是前面所说的没有明确的模式;

所以这种情况,可以使用对象传值来解决,然后在 createStore 函数中来 merge 这些选项。

const options = { 
  state: {
    count: 0
  }
}

createStore(options);

function createStore(options) {
  // ...

  // ...
  return new Vuex.Store(merge(defadefaultStoreConfig, options)); 
}

不要使用 ...Object.assign() 来实现 merge,因为它们处理对象的合并遇到同名属性的时候是替换,而不是添加;

const a = {
  b: {
    name: 'jack',
    age: 18
  }
}

Object.assign(a, { b: { sex: '男' }});  
// 你可能原本想将sex合并到b中,但是合并之后就会只有sex属性了,name age就没了,整个b被替换掉
//  ... 解构也是一样

上面的代码同样存在一个问题,需要注意就是当使用合并的时候,你的真实意图是使用空值(专指空对象和空数组)来覆盖某一个值,但是对象和数组合并的实现,空值的时候会被忽略,不会合并到默认的选项中,类似下面这种。

const a = [1, 2];
merge(a, []); // 你可能这时候想使用的是替换,却因为合并的实现却只能变成添加,因此相当于a没有变化。

总结,使用场景下,当你使用对象传值然后合并选项的时候,传值数组改变原有数组选项一般是直接替换,传值对象改变原本对象选项一般是添加合并,传值空对象则意味着使用空对象替换原有的值。

import mergeWith from 'loadsh.mergeWith';  // npm i -D loadsh.mergeWith

function customizer(defaultValue, srcValue) {
  // 如果传值的是数组 直接覆盖
  if (Array.isArray(srcValue)) {
    return srcValue;
  }

  if (srcValue instanceof Object && Object.keys(srcValue).length === 0) {
    return srcValue;
  }
}

// mergeWith的第三个参数用来改变合并策略
mergeWith(defaultOptions, overrideOptions, customizer);

参考 note6/#测试一个组件中的-vuex-代码示例 可以将其使用工厂函数改写为

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

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

function createStore(overrides) {
  const defaultStoreConfig = {
    getters: {
      displayItems: jest.fn(),
    },
    actions: {
      fetchListData: jest.fn(() => Promise.reslove())
    }
  }
  return new Vuex.Store(mergeWith(defaultStoreConfig, overrides, customizer));
}

function createWrapper(overrides) {
  const defaultOptions = {
    mocks: {
      $bar: {
        start: jest.fn(),
        finish: jest.fn(),
        fail: jest.fn(),
      }
    },
    localVue,
    store: createStore()
  }

  return shallowMount(
    ItemList, // 这里就没有必要抽象了,因为单元测试都只针对某一个组件 以及这个组件用于的默认选项
    mergeWith(defaultOptions, overrides, customizer)
  )
}


describe('ItemList', () => {

  test('测试 displayItems getters, 是否正常获取到数据并渲染', () => {
    const items = [{}, {}, {}];
    const store = createStore({
      getters: {
        displayItems: items
      }
    });

    const wrapper = createWrapper({ store });
    const Items = wrapper.findAll(Item);
    expect(Items).toHaveLength(items.length);
    Items.wrappers.foeEach((wrapper, i) => {
      expect(wrapper.vm.item).toBe(items[i]);
    })
  })

  test('测试 $bar 的 start 方法是否正常被调用', () => {
    const mocks = {
      $bar: {
        start: () => jest.fn(),
      }
    }
    const wrapper = createWrapper({ mocks });
    expect(mocks.$bar.start).toHaveBeenCalled();
  })

  test('测试 $bar 的 finish 方法是否正常被调用', () => {
    export.assertions(1);
    const mocks = {
      $bar: {
        finish: () => jest.fn(),
      }
    }
    
    const wrapper = createWrapper({ mocks });
    await flushPromises();
    expect(mocks.$bar.finish).toHaveBeenCalled();
  })

  test('测试 Action 是否被正确分发', () => {
    export.assertions(1);
    const store = createStore();
    store.dispatch = jest.fn(() => Promise.resolve());
    const wrapper = createWrapper({ store });
    expect(store.dispatch).toHaveBeenCalledWith('fetchListData', { type: 'top'});
  })

  test('测试 Action 抛出一个错误时,$bar 的 fail 方法是否被调用', () => {
    export.assertions(1);
    const mocks = {
      $bar: {
        fail: () => jest.fn(),
      }
    }

    const store = createStore({
      actions: {
        fetchListData: jest.fn(() => Promise.reject()) // 默认是正常resolve,这里需要测试异常所以使用 reject 覆盖
      }
    });

    const wrapper = createWrapper({ store, mocks });
    await flushPromises();
    expect(mocks.$bar.fail).toHaveBeenCalled();
  })
})