介绍

Vue-test-utils是Vue的官方的单元测试框架,它提供了一系列非常方便的工具,使我们更加轻松的为Vue构建的应用来编写单元测试。主流的 JavaScript 测试运行器有很多,但 Vue Test Utils 都能够支持。它是测试运行器无关的。

Jest,是由Facebook开发的单元测试框架,也是Vue推荐的测试运行器之一。Vue对它的评价是:

Jest 是功能最全的测试运行器。它所需的配置是最少的,默认安装了 JSDOM,内置断言且命令行的用户体验非常好。不过你需要一个能够将单文件组件导入到测试中的预处理器。我们已经创建了 vue-jest 预处理器来处理最常见的单文件组件特性,但仍不是 vue-loader 100% 的功能。

我认为可以这样理解,Vue-test-utils在Vue和Jest之前提供了一个桥梁,暴露出一些接口,让我们更加方便的通过Jest为Vue应用编写单元测试。

安装

通过Vue-cli创造模板脚手架时,可以选择是否启用单元测试,并且选择单元测试框架,这样Vue就帮助我们自动配置好了Jest。

如果是后期添加单元测试的话,首先要安装Jest和Vue Test Utils:

npm install --save-dev jest @vue/test-utils

然后在package.json中定义一个单元测试的脚本。

// package.json
{
  "scripts": {
    "test": "jest"
  }
}

为了告诉Jest如何处理*.vue文件,需要安装和配置vue-jest预处理器:

npm install --save-dev vue-jest

接下来在jest.conf.js配置文件中进行配置:

module.exports = {
  moduleFileExtensions: ['js', 'json', 'vue'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  transform: {
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    '.*\\.(vue)$': '<rootDir>/node_modules/vue-jest'
  },
}

其他的具体的配置可以参考官方文档

配置好了之后,就可以开始编写单元测试了。

import { mount } from '@vue/test-utils'
import Component from './component'

describe('Component', () => {
  test('是一个 Vue 实例', () => {
    const wrapper = mount(Component)
    expect(wrapper.isVueInstance()).toBeTruthy()
  })
})

上面的例子中,就是通过vue-test-utils提供的mount方法来挂载组件,创建包裹器和Vue实例

如果不使用vue-test-utils也是可以挂载组件的:

import Vue from 'vue';
import Test1 from '@/components/Test1';

const Constructor = Vue.extend(HelloWorld);
const vm = new Constructor().$mount();

启用单元测试的命令:

npm run unit

可以在后面加上-- --watch启动监听模式

别名配置

使用别名在Vue中很常见,可以让我们避免使用复杂、易错的相对路径:

import Page from '@/components/Test5/Test5'

上面的@就是别名,在使用Vue-cli搭建的项目中,默认已经在webpack.base.conf.js中对@进行了配置:

module.exports = {
  ...
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': path.join(__dirname, '..', 'src')
    }
  },
}

同样,使用Jest时也需要在Jest的配置文件jest.conf.js中进行配置

"jest": {
  "moduleNameMapper": {
    '^@/(.*)$': "<rootDir>/src/$1",
  },
...

Shallow Rendering

创建一个App.vue:

<template>
  <div id="app">
    <Page :messages="messages"></Page>
  </div>
</template>

<script>
  import Page from '@/components/Test1'

  export default {
    name: 'App',
    data() {
      return {
        messages: ['Hello Jest', 'Hello Vue']
      }
    },
    components: {
      Page
    }
  }
</script>

然后创建一个Test1组件

<template>
  <div>
    <p v-for="message in messages" :key="message">{{message}}</p>
  </div>
</template>

<script>
    export default {
    props: ['messages'],
    data() {
      return {}
    }
  }
</script>

针对App.vue编写单元测试文件App.spec.js

// 从测试实用工具集中导入 `mount()` 方法
import { mount } from 'vue-test-utils';
// 导入你要测试的组件
import App from '@/App';

describe('App.test.js', () => {
  let wrapper,
    vm;

  beforeEach(() => {
    wrapper = mount(App);
    vm = wrapper.vm;
    wrapper.setProps({ messages: ['Cat'] })
  });

  it('equals messages to ["Cat"]', () => {
    expect(vm.messages).toEqual(['Cat'])
  });

  // 为App的单元测试增加快照(snapshot):
  it('has the expected html structure', () => {
    expect(vm.$el).toMatchSnapshot()
  })
});

执行单元测试后,测试通过,然后Jest会在test/__snapshots__/文件夹下创建一个快照文件App.spec.js.snap

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.test.js has the expected html structure 1`] = `
<div
  id="app"
>
  <div>
    <p>
      Cat
    </p>
  </div>
</div>
`;

通过快照我们可以发现,子组件Test1被渲染到App中了。

这里面有一个问题:单元测试应该以独立的单位进行。也就是说,当我们测试App时,不需要也不应该关注其子组件的情况。这样才能保证单元测试的独立性。比如,在created钩子函数中进行的操作就会给测试带来不确定的问题。

为了解决这个问题,Vue-test-utils提供了shallow方法,它和mount一样,创建一个包含被挂载和渲染的Vue组件的Wrapper,不同的创建的是被存根的子组件。

这个方法可以保证你关心的组件在渲染时没有同时将其子组件渲染,避免了子组件可能带来的副作用(比如Http请求等)

所以,将App.spec.js中的mount方法更改为shallow方法,再次查看快照

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`App.test.js has the expected html structure 1`] = `
<div
  id="app"
>
  <!---->
</div>
`;

可以看出来,子组件没有被渲染,这时候针对App.vue的单元测试就从组件树中被完全隔离了。��

测试DOM结构

通过mountshallowfindfindAll方法都可以返回一个包裹器对象,包裹器会暴露很多封装、遍历和查询其内部的Vue组件实例的便捷的方法。

其中,findfindAll方法都可以都接受一个选择器作为参数,find方法返回匹配选择器的DOM节点或Vue组件的Wrapper,findAll方法返回所有匹配选择器的DOM节点或Vue组件的WrappersWrapperArray

一个选择器可以是一个CSS选择器、一个Vue组件或是一个查找选项对象。

  • CSS选择器:可以匹配任何有效的CSS选择器
    • 标签选择器 (divfoobar)
    • 类选择器 (.foo.bar)
    • 特性选择器 ([foo][foo="bar"])
    • id 选择器 (#foo#bar)
    • 伪选择器 (div:first-of-type)
    • 符合选择器(div > #bar > .foodiv + .foo)
  • Vue组件:Vue 组件也是有效的选择器。
  • 查找选项对象:
    • Name:可以根据一个组件的name选择元素。wrapper.find({ name: 'my-button' })
    • Ref:可以根据$ref选择元素。wrapper.find({ ref: 'myButton' })

这样我们就可以对DOM的结构进行验证:

describe('Test for Test1 Component', () => {
  let wrapper,
    vm;

  beforeEach(() => {
    // wrapper = mount(App);
    wrapper = shallow(Test1, {
      propsData: {
        messages: ['bye']
      }
    });
  });

  it('is a Test1 component', () => {
    // 使用Vue组件选择器
    expect(wrapper.is(Test1)).toBe(true);
    // 使用CSS选择器
    expect(wrapper.is('.outer')).toBe(true);
    // 使用CSS选择器
    expect(wrapper.contains('p')).toBe(true)
  });
});

还可以进行一步对DOM结构进行更细致的验证:

// exists():断言 Wrapper 或 WrapperArray 是否存在。
it('不存在img', () = > {
  expect(wrapper.findAll('img').exists()).toBeFalsy()
});

// isEmpty():断言 Wrapper 并不包含子节点。
it('MyButton组件不为空', () = > {
  expect(wrapper.find(MyButton).isEmpty()).toBeFalsy()
});

// attributes():返回 Wrapper DOM 节点的特性对象
// classes():返回 Wrapper DOM 节点的 class 组成的数组
it('MyButton组件有my-class类', () = > {
  expect(wrapper.find(MyButton).attributes().class).toContain('my-button');
  expect(wrapper.find(MyButton).classes()).toContain('my-button');
})

测试样式

UI的样式测试为了测试我们的样式是否复合设计稿预期。同时通过样式测试我们可以感受当我们code变化带来的UI变化,以及是否符合预期。

  • inline style :如果样式是inline style,可以使用hasStyle来验证,也可以使用Jest的Snapshot Testing最方便。

    // hasStyle:判断是否有对应的内联样式
    it('MyButton组件有my-class类', () = > {
        expect(wrapper.find(MyButton).hasStyle('padding-top', '10')).toBeTruthy()
    })
  • CSS:属于E2E测试,把整个系统当作一个黑盒,只有UI会暴露给用户用来测试一个应用从头到尾的流程是否和设计时候所想的一样 。有专门的E2E测试框架。比较流行的E2E测试框架有nightwatch等,关于E2E测试框架的介绍可以参考这篇文章

测试Props

父组件向子组件传递数据使用Props,而子组件向父组件传递数据则需要在子组件出发父组件的自定义事件

当测试对父组件向子组件传递数据这一行为时,我们想要测试的当我们传递给子组件一个特定的参数,子组件是否会按照我们所断言的那样变现。

在初始化时向子组件传值,使用的方法是propsData

const wrapper = mount(Foo, {
  propsData: {
    foo: 'bar'
  }
})

也可以使用setProps方法:

const wrapper = mount(Foo)
wrapper.setProps({ foo: 'bar' })

我们传递给Test1组件的messages一个['bye']数组,来验证是否存在:

beforeEach(() = > {
  wrapper = mount(Test1, {
    propsData: {
      messages: ['bye']
    }
  });
});

// props:返回 Wrapper vm 的 props 对象。
it('接收到了bye作为Props', () = > {
  expect(wrapper.props().messages).toContain('bye')
});

有时候会对Props的Type、默认值或者通过validator对Prop进行自定义的验证

props: {
  messages: {
    type: Array,
    required: true,
    validator: (messages) = > messages.length > 1,
    default () {
      return [0, 2]
    }
  }
},

通过Vue实例的$options获取包括Props在内的初始化选项:

// vm.$options返回Vue实例的初始化选项
describe('验证Props的各个属性', () = > {
  wrapper = mount(Test1, {
    propsData: {
      messages: ['bye', 'bye', 'bye']
    }
  });
  const messages = wrapper.vm.$options.props.messages;
  it('messages is of type array', () = > {
    expect(messages.type).toBe(Array)
  });
  it('messages is required', () = > {
    expect(messages.required).toBeTruthy()
  });
  it('messages has at least length 2', () = > {
    expect(messages.validator && messages.validator(['a'])).toBeFalsy();
    expect(messages.validator && messages.validator(['a', 'a'])).toBeTruthy();
  });
  wrapper.destroy()
});

测试自定义事件

自定义事件要测试点至少有以下两个:

  1. 测试事件会被正常触发
  2. 测试事件被触发后的后续行为符合预期

具体到Test1组件和MyButton组件来看:

TEST1组件:

// TEST1
<MyButton class="my-button" style="padding-top: 10px" buttonValue="Me" @add="addCounter"></MyButton>

// 省略一些代码

methods: {
  addCounter(value) {
    this.count = value
  }
},

MyButton组件:

<button @click="increment">Click {{buttonValue}} {{innerCount}}</button>、

// 省略一些代码

data() {
  return {
    innerCount: 0
  }
},
computed: {},
methods: {
  increment() {
    this.innerCount += 1;
    this.$emit('add', this.innerCount)
  }
},

要测试的目的是:
1. 当MyButton组件的按钮被点击后会触发increment事件
2. 点击事件发生后,Test1组件的addCounter函数会被触发并且结果符合预期(及数字递增)

首先为MyButton编写单元测试文件:

describe('Test for MyButton Component', () => {
  const wrapper = mount(MyButton);

  it('calls increment when click on button', () => {
    // 创建mock函数
    const mockFn = jest.fn();
    // 设置 Wrapper vm 的方法并强制更新。
    wrapper.setMethods({
      increment: mockFn
    });
    // 触发按钮的点击事件
    wrapper.find('button').trigger('click');
    expect(mockFn).toBeCalled();
    expect(mockFn).toHaveBeenCalledTimes(1)
  })
});

通过setMethods方法用mock函数代替真实的方法,然后就可以断言点击按钮后对应的方法有没有被触发、触发几次、传入的参数等等。

现在我们测试了点击事件后能触发对应的方法,下面要测试的就是increment方法将触发Test1组件中自定义的add方法

// increment方法会触发add方法
it('triggers a addCounter event when a handleClick method is called', () = > {
  const wrapper = mount(MyButton);

  // mock自定义事件
  const mockFn1 = jest.fn();
  wrapper.vm.$on('add', mockFn1);

  // 触发按钮的点击事件
  wrapper.find('button').trigger('click');
  expect(mockFn1).toBeCalled();
  expect(mockFn1).toHaveBeenCalledWith(1);

  // 再次触发按钮的点击事件
  wrapper.find('button').trigger('click');
  expect(mockFn1).toHaveBeenCalledTimes(2);
  expect(mockFn1).toHaveBeenCalledWith(2);
})

这里使用了$on方法,将Test1自定义的add事件替换为Mock函数

对于自定义事件,不能使用trigger方法触发,因为trigger只是用DOM事件。自定义事件使用$emit触发,前提是通过find找到MyButton组件

// $emit 触发自定义事件
describe('验证addCounter是否被触发', () = > {
  wrapper = mount(Test1);
  it('addCounter Fn should be called', () = > {
    const mockFn = jest.fn();
    wrapper.setMethods({
      'addCounter': mockFn
    });
    wrapper.find(MyButton).vm.$emit('add', 100);
    expect(mockFn).toHaveBeenCalledTimes(1);
  });
  wrapper.destroy()
});

测试计算属性

创建Test2组件,实现功能是使用计算属性将输入框输入的字符翻转:

<template>
  <div class="wrapper">
    <label for="input">输入:</label>
    <input id="input" type="text" v-model="inputValue">
    <p>输出:{{outputValue}}</p>
  </div>
</template>

<script>
  export default {
    name: 'Test2',
    props: {
      needReverse: {
        type: Boolean,
        default: false
      }
    },
    data() {
      return {
        inputValue: ''
      }
    },
    computed: {
      outputValue () {
        return this.needReverse ? ([...this.inputValue]).reverse().join('') : this.inputValue
      }
    },
    methods: {},
    components: {}
  }
</script>

<style scoped>
  .wrapper {
    width: 300px;
    margin: 0 auto;
    text-align: left;
  }
</style>

Test2.spec.js中,可以通过wrapper.vm属性访问一个实例所有的方法和属性。这只存在于 Vue 组件包裹器中。

describe('Test for Test2 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(Test2);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  it('returns the string in normal order if reversed property is not true', () => {
    wrapper.setProps({needReverse: false});
    wrapper.vm.inputValue = 'ok';
    expect(wrapper.vm.outputValue).toBe('ok')
  });

  it('returns the string in normal order if reversed property is not provided', () => {
    wrapper.vm.inputValue = 'ok';
    expect(wrapper.vm.outputValue).toBe('ok')
  });

  it('returns the string in reversed order if reversed property is true', () => {
    wrapper.setProps({needReverse: true});
    wrapper.vm.inputValue = 'ok';
    expect(wrapper.vm.outputValue).toBe('ko')
  })

});

测试监听器

Vue提供的watch选项提供了一个更通用的方法,来响应数据的变化。

为Test添加侦听器:

watch: {
  inputValue: function(newValue, oldValue) {
    if (newValue.trim().length > 0 && newValue !== oldValue) {
      this.printNewValue(newValue)
    }
  }
},
methods: {
  printNewValue(value) {
    console.log(value)
  }
},

为了测试,首先开始测试前将consolelog方法用jestspyOn方法mock掉,最好在测试结束后通过mockClear方法将其重置,避免无关状态的引入。

describe('Test watch', () = > {
    let spy;
    beforeEach(() = > {
      wrapper = shallow(Test2);
      spy = jest.spyOn(console, 'log')
    });
    afterEach(() = > {
      wrapper.destroy();
      spy.mockClear()
    });
}

然后执行给inputValue赋值,按照预期,spy方法会被调用

it('is called with the new value in other cases', () = > {
  wrapper.vm.inputValue = 'ok';
  expect(spy).toBeCalled()
});

但是在执行之后我们发现并非如此,spy并未被调用,原因是:

watch中的方法被Vue**推迟**到了更新的下一个循环队列中去异步执行,如果这个watch被触发多次,只会被推送到队列一次。这种缓冲行为可以有效的去掉重复数据造成的不必要的性能开销。

所以当我们设置了inputValue'ok'之后,watch中的方法并没有立刻执行,但是expect却执行了,所以断言失败了。

解决方法就是将断言放到$nextTick中,在下一个循环队列中执行,同时在expect后面执行Jest提供的done()方法,Jest会等到done()方法被执行才会结束测试。

it('is called with the new value in other cases', (done) = > {
  wrapper.vm.inputValue = 'ok';
  wrapper.vm.$nextTick(() = > {
    expect(spy).toBeCalled();
    done()
  })
});

在测试第二个情况时,由于对inputValue赋值时spy会被执行一次,所以需要清除spy的状态,这样才能得出正确的预期:

it('is not called with same value', (done) = > {
  wrapper.vm.inputValue = 'ok';
  wrapper.vm.$nextTick(() = > {
    // 清除已发生的状态
    spy.mockClear();
    wrapper.vm.inputValue = 'ok';
    wrapper.vm.$nextTick(() = > {
      expect(spy).not.toBeCalled();
      done()
    })
  })
});

测试方法

单元测试的核心之一就是测试方法的行为是否符合预期,在测试时要避免一切的依赖,将所有的依赖都mock掉。

创建Test3组件,输入问题后,点击按钮后,使用axios发送HTTP请求,获取答案

<template>
  <div class="wrapper">
    <label for="input">问题:</label>
    <input id="input" type="text" v-model="inputValue">
    <button @click="getAnswer">click</button>
    <p>答案:{{answer}}</p>
    <img :src="src">
  </div>
</template>

<script>
  import axios from 'axios';

  export default {
    name: 'Test3',
    data() {
      return {
        inputValue: 'ok?',
        answer: '',
        src: ''
      }
    },
    methods: {
      getAnswer() {
        const URL = 'https://yesno.wtf/api';
        return axios.get(URL).then(result => {
          if (result && result.data) {
            this.answer = result.data.answer;
            this.src = result.data.image;
            return result
          }
        }).catch(e => {})
      }
    }
  }
</script>

<style scoped>
  .wrapper {
    width: 500px;
    margin: 0 auto;
    text-align: left;
  }
</style>

这个例子里面,我们仅仅关注测试getAnswer方法,其他的忽略掉。为了测试这个方法,我们需要做的有:

  1. 我们不需要实际调用axios.get方法,需要将它mock掉
  2. 我们需要测试是否调用了axios方法(但是并不实际触发)并且返回了一个Promise对象
  3. 返回的Promise对象执行了回调函数,设置用户名和头像

我们现在要做的就是mock掉外部依赖。Jest提供了一个很好的mock系统,让我们能够很轻易的mock所有依赖,前面我们用过jest.spyOn方法和jest.fn方法,但对于上面的例子来说,仅使用这两个方法是不够的。

我们现在要mock掉整个axios模块,使用的方法是jest.mock,就可以mock掉依赖的模块。

jest.mock('dependency-path', implementationFunction)

Test3.spec.js中,首先将axios中的get方法替换为我们的mock函数,然后引入相应的模块

jest.mock('axios', () => ({
  get: jest.fn()
}));
import { shallow } from 'vue-test-utils';
import Test3 from '@/components/Test3';
import axios from 'axios';

然后测试点击按钮后,axios的get方法是否被调用:

describe('Test for Test3 Component', () => {
  let wrapper;

  beforeEach(() => {
    axios.get.mockClear();
    wrapper = shallow(Test3);
  });

  afterEach(() = > {
    wrapper.destroy()
  });

  // 点击按钮后调用了 getAnswer 方法
  it('getAnswer Fn should be called', () => {
    const mockFn = jest.fn();
    wrapper.setMethods({getAnswer: mockFn});
    wrapper.find('button').trigger('click');
    expect(mockFn).toBeCalled();
  });

  // 点击按钮后调用了axios.get方法
  it('axios.get Fn should be called', () => {
    const URL = 'https://yesno.wtf/api';
    wrapper.find('button').trigger('click');
    expect(axios.get).toBeCalledWith(URL)
  });
});

测试结果发现,虽然我们的mock函数被调用了,但是控制台还是报错了,原因是我们mock的axios.get方法虽然被调用了,但是并没有返回任何值,所以报错了,所以下一步我们要给get方法返回一个Promise,查看方法能否正确处理我们返回的数据

jest.fn()接受一个工厂函数作为参数,这样就可以定义其返回值

const mockData = {
  data: {
    answer: 'mock_yes',
    image: 'mock.png'
  }
};
jest.mock('axios', () => ({
  get: jest.fn(() => Promise.resolve(mockData))
}));

getAnswer是一个异步请求,Jest提供的解决异步代码测试的方法有以下三种:

  1. 回调函数中使用done()参数
  2. Pomise
  3. Aysnc/Await

第一种是使用在异步请求的回调函数中使用Jest提供的叫做done的单参数,Jest会等到done()执行结束后才会结束测试。

我们使用第二种和第三种方法来测试getAnswer方法的返回值,前提就是在方法中返回一个Promise。(一般来说,在被测试的方法中给出一个返回值会让测试更加容易)。 Jest会等待Promise解析完成。 如果承诺被拒绝,则测试将自动失败。

// axios.get方法返回值(Promise)
it('Calls get promise result', () = > {
  return expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData);
});

或者可以使用第三种方法,也就是使用asyncawait来测试异步代码:

// 可以用 Async/Await 测试 axios.get 方法返回值
it('Calls get promise result 3', async() = > {
  const result = await wrapper.vm.getAnswer();
  expect(result).toEqual(mockData)
});

Jest都提供了resolvesrejects方法作为thencatch的语法糖:

it('Calls get promise result 2', () = > {
  return wrapper.vm.getAnswer().then(result = > {
    expect(result).toEqual(mockData);
  })
});

it('Calls get promise result 4', async() = > {
  await expect(wrapper.vm.getAnswer()).resolves.toEqual(mockData)
});

mock依赖

我们可以创建一个__mocks__文件夹,将mock文件放入其中,这样就不必在每个测试文件中去单独的手动mock模块的依赖

__mocks__文件夹下创建axios.js文件:

// test/__mocks__/axios.js
const mock = {
  get: jest.fn(() => Promise.resolve({
    data: {
      answer: 'mock_yes',
      image: 'mock.png'
    }
  }))
};
export default mock

这样就可以将Test3.spec.js中的jest.mock部分代码移除了。Jest会自动在__mocks__文件夹下寻找mock的模块,但是有一点要注意,模块的注册和状态会一直被保存,所有如果我们在Test3.spec.js最后增加一条断言:

// 如果不清除模块状态此条断言会失败
it('Axios should not be called here', () = > {
  expect(axios.get).not.toBeCalled()
});

因为我们在beforeEach中添加了axios.get的状态清除的语句 axios.get.mockClear(),所以上面的断言会通过,否则会失败。

也可以用另外resetModulesclearAllMocks来确保每次开始前都重置模块和mock依赖的状态。

beforeEach(() = > {
  wrapper = shallow(Test3);
  jest.resetModules();
  jest.clearAllMocks();
});

我们在项目中有时候会根据需要对不同的Http请求的数据进行Mock,以MockJS为例,一般每个组件(模块)都有对应的mock文件,然后通过index.js导入到系统。Jest也可以直接将MockJS的数据导入,只需要在setup.js中导入MockJS的index.js文件即可

测试插槽

插槽(slots)用来在组件中插入、分发内容。创建一个使用slots的组件Test4

// TEST4
<MessageList>
   <Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>

// MessageList
<ul class="list-messages">
  <slot></slot>
</ul>

// Message
<li>{{message}}</li>

在测试slots时,我们的关注点是slots中的内容是否在组件中出现在该出现的位置,测试方法和前面介绍的测试DOM结构的方法相同。

具体到例子中来看,我们要测试的是:Message组件是否出现在具有list-messages的类的ul中。在测试时,为了将slots传递给MessageList组件,我们在MessageList.spec.js中的mount或者shallow方法中使用slots属性

import { mount } from 'vue-test-utils';
import MessageList from '@/components/Test4/MessageList';

describe('Test for MessageList of Test4 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = mount(MessageList, {
      slots: {
        default: '<div class="fake-msg"></div>'
      }
    });
  });

  afterEach(() => {
    wrapper.destroy()
  });

  //  组件中应该通过slots插入了div.fake-msg
  it('Messages are inserted in a ul.list-messages element', () => {
    const list = wrapper.find('ul.list-messages');
    expect(list.contains('div.fake-msg')).toBeTruthy()
  })
});

为了测试内容是否通过插槽插入了组件,所以我们伪造了一个div.fake-msg通过slots选项传入MessageList组件,断言组件中应该存在这个div

不仅如此,slots选项还可以传入组件或者数组:

import AnyComponent from 'anycomponent'

mount(MessageList, {
  slots: {
    default: AnyComponent // or [AnyComponent, AnyComponent]
  }
})

这里面有一个问题,例如我们想测试Message组件是否通过插槽插入了MessageList组件中,我们可以将slots选项中传入Message组件,但是由于Message组件需要传入message作为Props,所以按照上面的说明,我们应该这样做:

beforeEach(() = > {
  const fakeMessage = mount(Message, {
    propsData: {
      message: 'test'
    }
  });
  wrapper = mount(MessageList, {
    slots: {
      default: fakeMessage
    }
  })
});

对应的断言是:

//  组件中应该通过slots插入了Message,并且传入的文本是test
it('Messages are inserted in a ul.list-messages element', () = > {
  const list = wrapper.find('ul.list-messages');
  expect(list.contains('li')).toBeTruthy();
  expect(list.find('li').text()).toBe('test')
})

但是这会失败,查了资料,貌似不能通过这种方式mounted的组件传入slots中。

虽然如此,我们可以而通过渲染函数(render function)来作为一种非正式的解决方法:

const fakeMessage = {
  render(h) {
    return h(Message, {
      props: {
        message: 'test'
      }
    })
  }
};
wrapper = mount(MessageList, {
  slots: {
    default: fakeMessage
  }
})

测试命名插槽(Named Slots)

测试命名插槽和默认插槽原理相同,创建Test5组件,里面应用新的MessageList组件,组件中增加一个给定名字为header的插槽,并设定默认内容:

<div>
  <header class="list-header">
    <slot name="header">This is a default header</slot>
  </header>
  <ul class="list-messages">
    <slot></slot>
  </ul>
</div>

在Test5中就可以使用这个命名插槽:

<MessageList>
  <header slot="header">Awesome header</header>
  <Message v-for="message in messages" :key="message" :message="message"></Message>
</MessageList>

MessageList组件进行测试时,首先测试组件中是否渲染了命名插槽的默认内容:

// 渲染命名插槽的默认内容
it('Header slot renders a default header text', () = > {
  const header = wrapper.find('.list-header');
  expect(header.text()).toBe('This is a default header')
});

然后测试插槽是否能插入我们给定的内容,只需要将mount方法中的slots选项的键值default改为被测试的插槽的name即可:

// 向header插槽中插入内容
it('Header slot is rendered withing .list-header', () = > {
  wrapper = mount(MessageList, {
    slots: {
      header: '<header>What an awesome header</header>'
    }
  });
  const header = wrapper.find('.list-header');
  expect(header.text()).toBe('What an awesome header')
})

测试debounce

我们经常使用lodash的debounce方法,来避免一些高频操作导致的函数在短时间内被反复执行,比如在Test6组件中,对button的点击事件进行了debounce,频率为500ms,这就意味着如果在500ms内如果用户再次点击按钮,handler方法会被推迟执行:

<template>
  <div class="outer">
    <p>This button has been clicked {{count}}</p>
    <button @click="addCounter">click</button>
  </div>
</template>

<script>
  import _ from 'lodash';
  export default {
    data() {
      return { count: 0 }
    },
    methods: {
      addCounter: _.debounce(function () {
        this.handler()
      }, 500),
      handler() {
        this.count += 1;
      }
    }
  }
</script>

在编写Test6的单元测试时,我们有一个这样的预期:当addCounter方法被触发时,500ms内没有任何后续操作,handler方法会被触发

如果没有进行特殊的处理,单元测试文件应该是这样的:

import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';

describe('Test for Test6 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(Test6);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  it('test for lodash', () => {
    const mockFn2 = jest.fn();
    wrapper.setMethods({ handler: mockFn2 });
    wrapper.vm.addCounter();
    expect(mockFn2).toHaveBeenCalledTimes(1);
  })
});

测试结果发现,addCounter被触发时handler方法并没有执行

因为lodash中debounce方法涉及到了setTimeout,`hanlder方法应该是在500ms后执行,所以在此时执行时方法没有执行。

所以我们需要在Jest中对setTimeout进行特殊的处理:Jest提供了相关的方法,我们需要使用的是jest.useFakeTimers()jest.runAllTimers()

前者是用来让Jest模拟我们用到的诸如setTimeoutsetInterval等计时器,而后者是执行setTimeoutsetInterval等异步任务中的宏任务(macro-task)并且将需要的新的macro-task放入队列中并执行,更多信息的可以参考官网的timer-mocks

所以对test6.spec.js进行修改,在代码开始增加jest.useFakeTimers(),在触发addCounter方法后通过jest.runAllTimers()触发macor-task任务

jest.useFakeTimers();

import { shallow } from 'vue-test-utils';
import Test6 from '@/components/Test6';
import _ from 'lodash';

describe('Test for Test6 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(Test6);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  it('test for lodash', () => {
    const mockFn2 = jest.fn();
    wrapper.setMethods({ handler: mockFn2 });
    wrapper.vm.addCounter();

    jest.runAllTimers();

    expect(mockFn2).toHaveBeenCalledTimes(1);
  })
});

结果还是失败,报错原因是:

Ran 100000 timers, and there are still more! Assuming we’ve hit an infinite recursion and bailing out…

程序陷入了死循环,换用Jest提供额另外一个API:jest.runOnlyPendingTimers(),这个方法只会执行当前队列中的macro-task,遇到的新的macro-task则不会被执行

jest.runAllTimers()替换为jest.runOnlyPendingTimers()后,上面的错误消失了,但是handler仍然没有被执行

在查了许多资料后,这可能是lodash的debounce机制与jest的timer-mocks 无法兼容,如果有人能够解决这个问题希望能够指教。

这样的情况下,我们退而求其次,我们不去验证addCounter是否会被debounce,因为debounce是第三方模块的方法,我们默认认为是正确的,我们要验证的是addCounter能够正确触发handler方法即可。

所以我们可以另辟蹊径,通过mock将lodash的debounce修改为立即执行的函数,我们要做的是为lodashdebounce替换为jest.fn(),并且提供一个工厂函数,返回值就是传入的函数

import _ from 'lodash';

jest.mock('lodash', () => ({
  debounce: jest.fn((fn => fn))
}));

在如此修改后,测试通过,handler方法正确执行

同一个方法的多次mock

在一个组件中,我们可能会多次用到同一个外部的方法,但是每次返回值是不同的,我们可能要对它进行多次不同的mock

举个例子,在组件Test7中,mounted的时候forData返回一个数组,经过map处理后赋给text,点击getResult按钮,返回一个01的数字,根据返回值为result赋值

<template>
  <div class="outer">
    <p>{{text}}</p>
    <p>Result is {{result}}</p>
    <button @click="getResult">getResult</button>
  </div>
</template>

<script>
  import { forData } from '@/helper';
  import axios from 'axios'

  export default {
    data() {
      return {
        text: '',
        result: ''
      }
    },
    async mounted() {
      const ret = await forData(axios.get('text.do'));
      this.text = ret.map(val => val.name)
    },
    methods: {
      async getResult() {
        const res = await forData(axios.get('result.do'));
        switch (res) {
          case 0 : {
            this.result = '000';
            break
          }
          case 1 : {
            this.result = '111';
            break
          }
        }
      },
    }
  }
</script>

针对getResult方法编写单元测试,针对两种返回值编写了两个用例,在用例中将forData方法mock掉,返回值是一个Promise值,再根据给定的返回值,判断结果是否符合预期:

describe('Test for Test7 Component', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(Test7);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  it('test for getResult', async () => {
    // 设定forData返回值
    const mockResult = 0;
    const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
    helper.forData = mockFn;

    // 执行
    await wrapper.vm.getResult();
    // 断言
    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(wrapper.vm.result).toBe('000')
  });

  it('test for getResult', async () => {
    // 设定forData返回值
    const mockResult = 1;
    const mockFn = jest.fn(() => (Promise.resolve(mockResult)));
    helper.forData = mockFn;

    // 执行
    await wrapper.vm.getResult();
    // 断言
    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(wrapper.vm.result).toBe('111')
  })
});

运行测试用例,虽然测试用例全部通过,但是控制台仍然报错了:

(node:17068) UnhandledPromiseRejectionWarning: TypeError: ret.map is
not a function

为什么呢?

原因就是在于,在第一个用例运行之后,代码中的forData方法被我们mock掉了,所以在运行第二个用例的时候,执行mounted的钩子函数时,forData返回值就是我们在上个用例中给定的1,所以使用map方法会报错

为了解决这个问题,我们需要在beforeEach(或afterEach)中,重置forData的状态,如果在代码中使用了MockJS的情况下,我们只需要让默认的forData获取的数据走原来的路径,由MockJS提供假数据即可,这样我们只需要在一代码的最开始将forData保存,在beforeEach使用restoreAllMocks方法重置状态,然后在恢复forData状态,然后每个用例中针对forData进行单独的mock即可

const test = helper.forData;

describe('Test for Test7 Component', () => {
  let wrapper;

  beforeEach(() => {
    jest.restoreAllMocks();
    helper.forData = test;
    wrapper = shallow(Test7);
  });

  afterEach(() => {
    wrapper.destroy()
  });

  // 用例不变

如果没有使用MockJS,那么都需要我们提供数据,就需要在afterEach中提供mounted时需要的数据:

beforeEach(() = > {
  jest.restoreAllMocks();
  const mockResult = [{ name: 1}, {name: 2}];
  helper.forData = jest.fn(() = > (Promise.resolve(mockResult)));
  wrapper = shallow(Test7);
});

这样处理过后,运行用例通过,并且控制台也不会报错了。

如果是在同一个方法中遇到了需要不同返回结果的forData,比如下面的getQuestion方法:

async getQuestion() {
  const r1 = await forData(axios.get('result1.do'));
  const r2 = await forData(axios.get('result2.do'));
  const res = r1 + r2;
  switch (res) {
    case 2:
      {
        this.result = '222';
        break
      }
    case 3:
      {
        this.result = '333';
        break
      }
  }
},

通过forData发出了两个不同的HTTP请求,返回结果不同,这时我们在测试时就需要使用mockImplementationOnce方法,这个方法mock的函数只被调用一次,多次调用时就会根据定义时的顺序依次调用mock函数,所以测试用例如下:

it('test for getQuestion', async() = > {
  // 设定forData返回值
  const mockFn = jest.fn()
    .mockImplementationOnce(() = > (Promise.resolve(1)))
    .mockImplementationOnce(() = > (Promise.resolve(2)));
  helper.forData = mockFn;
  // 执行
  await wrapper.vm.getQuestion();
  // 断言
  expect(mockFn).toHaveBeenCalledTimes(2);
  expect(wrapper.vm.result).toBe('333')
});

测试用例通过,并且控制台无报错。

参考

Logo

前往低代码交流专区

更多推荐