基于vitest+vue test utils+vite+vue3+pinia的单元测试实践

2,895 阅读4分钟

1.前言

近期对现有的前端项目新增了单元测试,特此对这段时间用到的一些单元测试知识做一下总结。

2.单元测试工具

项目的技术选型是vue3+vite+Pinia,单元测试工具采用vitest+Vue Test Utils

  • vitest:vitest是vite项目的首选测试框架,可以与vite共用配置、转换器、解析器和插件,提供类jest的api,方便上手。官网链接
  • Vue Test Utils:Vue Test Utils 是 Vue.js 官方的单元测试实用工具库,可以实现vue单文件的测试。官网链接

3.快速起步

3.1.安装vitest

npm install -D vitest @vue/test-utils jsdom

3.2.添加配置

vite.config.js添加test配置项,详细配置参考配置文档

import { defineConfig } from 'vite'

export default defineConfig({
  // ...
  test: {
    environment: 'jsdom',
  },
})

3.3.package.json新增测试命令

{
  "scripts": {
    "test": "vitest"
  }
}

3.4.一个单元测试示例

待测试的组件HelloWorld.vue

<template>
  <h1 class="name">{{ name }}</h1>
</template>

<script setup>
import { ref } from 'vue';
const name = ref('hello world!');
</script>

<style scoped>
</style>

在项目的根目录新建__test__目录,并在目录下新建hellowworld.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';

describe('测试示例', () => {
  it('hellow world', () => {
    const wrapper = mount(HelloWorld);
    expect(wrapper.html()).toMatchSnapshot(); // 快照测试
    expect(wrapper.find('.name').text()).toBe('hello world!');
  });
});

我们使用常见的describeit语法,由 vitest 提供。describe表示测试会包含什么。it表示该段的测试主题。随着我们为组件添加更多特性,在测试中就会添加更多it块。

我们用mount渲染组件,获取wrapper的变量,再通过find获取class属性为name的元素,断言文案是hello world!。

命令行运行npm run test,完成一次单元测试。

接下来将介绍如何对vue组件进行单元测试。

4.案例实践

4.1.组件渲染

mount和shallowMount

Vue Test Utils提供两种渲染方式,mountshallowMount。区别是mount会渲染子组件,shallowMount把子组件渲染为stub组件。

假如单元测试用例不涉及子组件功能测试的话,使用shallowMount更合理。

mountshallowMount的第二个参数提供可以传入propsData、slots等内容,可以挂载到组件实例上,详见挂载选项

通常一个describe块只需要渲染一次组件,因此我们可以使用beforeEach钩子函数实现组件的共享。

import {
  describe, expect, it, beforeEach, afterEach
} from 'vitest';
import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';

describe('测试示例', () => {
  let wrapper = null;
  
  beforeEach(() => {
    wrapper = mount(HelloWorld); // 挂载组件
  });

  it('hellow world', () => {
    expect(wrapper.find('.name').text()).toBe('hello world!');
  });
  
  it('hellow world isVisible', () => {
    wrapper.find('.name').isVisible();
  });
});

获取dom元素和子组件

Vue Test Utils提供类似jQuery的css选择器方式获取dom。

import Parent from './Parent.vue'
import Child from './Child.vue'

let wrapper = mount(Parent); // 挂载组件
wrapper.find('.name'); // 获取类名为name的元素
wrapper.findAll('.namelist'); // 获取类名为namelist的元素集合
wrapper.findComponent(Child); // 获取parent中的一个child子组件
wrapper.findComponentAll(Child); // 获取parent中的一个child子组件集合

获取组件实例

可以通过vm获取组件的实例,可以获取和调用组件的数据和方法。

wrapper.vm; // 获取根组件实例
wrapper.findComponent(Child).vm; // 获取子组件实例
wrapper.findComponent(Child).vm.$emit('change'); // 触发子组件事件
wrapper.findComponent(Child).vm.getList(); // 触发子组件的getList事件

其它属性

mount提供html、class等信息的获取,详见wrapper-methods

4.2.props测试

mountshallowMount的挂载选项提供propsData参数实现子组件的props值传递。同时可以使用setProps动态更改props值。

待测试的组件Props.vue

<template>
  <div class="name">
    {{name}}
  </div>
</template>

<script setup>
const props = defineProps(['name'])
</script>

<style lang="scss" scoped>
</style>

测试文件props.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Props from '@/components/Props.vue';

describe('props测试示例', () => {
  it('测试name传值', async () => {
    const name = '张三';
    const newName = '李四';
    const wrapper = mount(Props, {
      // 往子组件传递name值
      propsData: {
        name
      }
    });
    expect(wrapper.find('.name').text()).toBe(name);
    await wrapper.setProps({ name: newName }) // 中途修改props的值
    expect(wrapper.find('.name').text()).toBe(newName);
  });
});

4.3.dom事件测试

Vue Test Utils提供triggerAPI实现鼠标、键盘的时间触发。要注意的是,trigger会返回一个Promise,当这个Promise被解决时,才确保组件已经被更新。

下面是一个点击事件的示例。待测试的组件Count.vue

<template>
  <div>
    <button class="add" @click="add">点我</button>
    <p class="count">{{count}}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);
function add() {
  count.value++;
}
</script>

<style lang="scss" scoped>

</style>

测试文件trigger.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Count from '@/components/Count.vue';

describe('trigger测试示例', () => {
  it('测试点击事件', async () => {
    const wrapper = mount(Count);
    await wrapper.find('.add').trigger('click'); // 触发click事件
    expect(wrapper.find('.count').text()).toBe('1');
  });
});

4.4.emit事件测试

Vue Test Utils提供emittedAPI获取已经触发的事件。

待测试的组件Emit.vue

<template>
  <div>
    <button class="btn" @click="clickMe">点我</button>
  </div>
</template>

<script setup>
const emit = defineEmits(['change'])
function clickMe() {
  emit('change', true)
}
</script>

<style lang="scss" scoped>
</style>

测试文件emit.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Emit from '@/components/Emit.vue';

describe('emit测试示例', () => {
  it('change事件触发测试', () => {
    const wrapper = mount(Emit);
    wrapper.find('.btn').trigger('click');
    expect(wrapper.emitted().change).toMatchObject([[true]]); // 获取change事件
  });
});

4.5.插槽测试

Vue Test Utils提供slots选项往组件中放入插槽

普通插槽

待测试的组件Slot.vue

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script setup>
</script>

<style lang="scss" scoped>
</style>

测试文件slot.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Slot from '@/components/Slot.vue';

describe('测试示例', () => {
  it('普通插槽', () => {
    const wrapper = mount(Slot, {
      slots: {
        default: '<div class="content">我是内容</div>' // 插槽内容
      }
    });
    expect(wrapper.find('.content').text()).toBe('我是内容');
  });
});

具名插槽

slots选项的key对应<slot>name属性,从而实现具名插槽。

待测试的组件NameSlot.vue

<template>
  <div>
    <header>
      <slot name="header" />
    </header>
    <main>
      <slot name="main" />
    </main>
    <footer>
      <slot name="footer" />
    </footer>
  </div>
</template>

<script setup>
</script>

<style lang="scss" scoped>
</style>

测试文件nameslot.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import NameSlot from '@/components/NameSlot.vue';

describe('测试示例', () => {
  it('普通插槽', () => {
    const wrapper = mount(NameSlot, {
      slots: {
        header: '<div class="header">Header</div>',
        main: '<div class="content">Main Content</div>',
        footer: '<div class="footer">Footer</div>'
      }
    });
    expect(wrapper.find('.content').text()).toBe('Main Content');
    expect(wrapper.find('.header').text()).toBe('Header');
    expect(wrapper.find('.footer').text()).toBe('Footer');
  });
});

4.6.表单测试

setValue可以设置一个文本控件或select元素的值并更新 v-model 绑定的数据。

待测试的组件Login.vue,这是一个登录组件,输入账号密码进行登录。

<template>
  <div>
    <section>
      <input class="account" v-model="account" type="text" placeholder="请输入账号">
    </section>

    <section>
      <input class="password" v-model="password" type="password" placeholder="请输入密码">
    </section>

    <section>
      <button class="login" @click="login">登录</button>
    </section>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const account = ref('');
const password = ref('');
const emit = defineEmits(['loginSuccess', 'loginFail']);

function login() {
  if(account.value === '123' && password.value === '1234') {
    emit('loginSuccess');
  } else {
    emit('loginFail');
  }
}
</script>

<style lang="scss" scoped>
</style>

测试文件login.test.js

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import Login from '@/components/Login.vue';

describe('测试示例', () => {
  it('登录', async () => {
    const wrapper = mount(Login);
    wrapper.find('.account').setValue('123'); // 填写账号
    wrapper.find('.password').setValue('1234'); // 填写密码
    wrapper.find('.login').trigger('click'); // 点击登录
    expect(wrapper.emitted('loginSuccess')[0]).toEqual([]);
  });
});

4.7.mock异步请求

在业务组件中,经常有异步请求的逻辑。假如单元测试中真实地调用接口,成本是比较大的,可以使用vitest提供的mock功能替代接口调用。

待测试的组件Request.vue。组件的逻辑很简单,点击按钮,请求数据,并把数据显示在页面上。

<template>
  <div>
    <button class="btn" @click="clickMe">点我</button>
    <p class="name">{{name}}</p>
  </div>
</template>

<script setup>
import { getList } from '@/api/request.js'
import { ref } from 'vue';
const name = ref('');

async function clickMe() {
  const res = await getList();
  name.value = res.data.name;
}
</script>

<style lang="scss" scoped>
</style>

添加mock文件。如下图所示,在被mock文件A的目录下新建一个__mocks__文件夹,文件夹新增一个文件B,B名称与A一致。

1664271380485.jpg

// 文件B的内容
export const getList = () => new Promise(resolve => {
  resolve({
    data: { name: '李四' }
  });
})

测试文件request.test.js

import {
  describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import Request from '@/components/Request.vue';
import flushPromises from 'flush-promises';

vi.mock('@/api/request.js'); // mock接口

describe('请求测试示例', () => {
  it('测试异步请求', async () => {
    const wrapper = mount(Request);
    await wrapper.find('.btn').trigger('click'); // 触发click事件
    await flushPromises(); // 等待异步完成
    expect(wrapper.find('.name').text()).toBe('李四');
  });
});

vitest提供vi.mock方法mock文件,只需传入路径,vitest会自动寻找__mocks__ 文件夹下存在同名文件。

要注意的是,异步请求后需要刷新所有处于pending状态或resolved状态的Promise,推荐使用flush-promises解决。

4.8.处理定时器

假如代码中存在setTimeoutsetInterval,且不想等待的时间太长,可以使用vi.useFakeTimers模拟定时器,减少等待的时间。

下面是一个倒计时的例子。

屏幕录制2022-09-28 09.52.59.2022-09-28 09_54_39.gif

待测试的组件CountDown.vue

<template>
  <div>
    <button class="btn" @click="start">开启倒计时</button>
    <p class="seconds">{{seconds}}s</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const seconds = ref(0); // 倒计时的秒数
const timer = ref(null);

// 开启倒计时
function start() {
  if(seconds.value !== 0) return;
  clearInterval(timer.value);
  countDownCallback();
  timer.value = setInterval(countDownCallback, 1000);
}

function countDownCallback() {
  if (seconds.value === 0) {
    seconds.value = 5;
  } else if (seconds.value - 1 <= 0) {
    seconds.value = 0;
    clearInterval(timer.value);
  } else {
    seconds.value--;
  }
}

</script>

<style lang="scss" scoped>

</style>

测试文件countdown.test.js

import {
  describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import CountDown from '@/components/CountDown.vue';
import flushPromises from 'flush-promises';

describe('倒计时测试示例', () => {
  
  it('测试倒计时', async () => {
    const wrapper = mount(CountDown);

    vi.useFakeTimers(); // 启用模拟计时器

    await wrapper.find('.btn').trigger('click'); // 开启倒计时
    await flushPromises();
    expect(wrapper.find('.seconds').text()).toBe('5s');

    await vi.advanceTimersByTime(3000); // 倒计时快进3s
    await flushPromises();
    expect(wrapper.find('.seconds').text()).toBe('2s');

    vi.runAllTimers(); // 调用每个被创建的计时器,直到计时器队列为空
    await flushPromises();
    expect(wrapper.find('.seconds').text()).toBe('0s');

    vi.useRealTimers(); // 关闭模拟计时器
  });
});

示例中,通过vi.useFakeTimers模拟计时器,同时通过vi.advanceTimersByTime快进倒计时,大大减少等待时间。最后通过vi.runAllTimers执行所有倒计时。

使用时要注意两点:

  1. 每次使用后,需要调用vi.useRealTimers()关闭模拟计时器。
  2. mount方法一定在vi.useFakeTimers前执行。

4.9.pinia

Vue Test Utils中使用pinia

pinna由state、getters、actions三部分组成,单元测试只需关注getters和actions。

  • getters:直接修改state值,断言getters。
  • actions:直接调用action,断言修改的state值和返回值。

一个pinia示例

import { defineStore } from 'pinia'

export const useUserInfo =  defineStore('userinfo', {
  state() {
    return {
      name: '张三',
      age: 14
    }
  },
  getters: {
    doubleAge: (state) => state.age * 2,
  },
  actions: {
    changeUserInfo(name, age) {
      this.name = name;
      this.age = age;
    },
  },
})

测试文件userinfostore.test.js pinna提供setActivePiniaapi方便我们直接激活pinna。

import {
  describe, expect, it, beforeAll, afterEach
} from 'vitest';

import { setActivePinia, createPinia } from 'pinia'
import { useUserInfo } from '@/store/userinfo';

describe('pinia测试示例', () => {
  let store = null;
  beforeAll(() => {
    setActivePinia(createPinia()); // 激活pinia
    store = useUserInfo()
  })

  afterEach(() => {
    store.$reset(); // 每次测试后,重置一下
  })

  it('测试changeUserInfo', () => {
    store.changeUserInfo('李四', 15);
    expect(store.name).toBe('李四');
    expect(store.age).toBe(15);
  });

  it('测试doubleAge', () => {
    // 修改age的值
    store.$patch({
      age: 16
    });
    expect(store.doubleAge).toBe(32);
  });
});

4.10.组件中的pinia测试

在项目中,因为pinia是一个vue插件,我们需要通过app.use安装插件。

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app');

Vue Test Utils允许你使用global.plugins安装插件。

下面的示例,使用了userinfo的state、getters和actions。

<template>
  <div>
    <p class="name">姓名: {{name}}</p>
    <p class="age">年龄: {{age}}</p>
    <p class="doubleAge">两倍年龄: {{doubleAge}}</p>
    <button class="change" @click="change">改变信息</button>
  </div>
</template>

<script setup>
import { useUserInfo } from '@/store/userinfo.js';
import { storeToRefs } from 'pinia';

const userinfo = useUserInfo();
const { name, age, doubleAge } = storeToRefs(userinfo);

function change() {
  userinfo.changeUserInfo('李四', 15);
}
</script>

<style lang="scss" scoped>
</style>

测试文件userinfo.test.js。我们直接使用global.plugins挂载pinia就可以了。

import {
  describe, expect, it
} from 'vitest';
import { mount } from '@vue/test-utils';
import UserInfo from '@/components/UserInfo.vue';
import { createPinia } from 'pinia';
import flushPromises from 'flush-promises';
const pinia = createPinia()

describe('测试示例', () => {
  it('vue中的pinia测试', async () => {
    const wrapper = mount(UserInfo, {
      global: {
        plugins: [pinia] // 安装pinia
      }
    });
    expect(wrapper.find('.name').text()).toBe('姓名: 张三');
    expect(wrapper.find('.age').text()).toBe('年龄: 14');
    expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 28');

    wrapper.find('.change').trigger('click'); // 改变store
    await flushPromises();
    expect(wrapper.find('.name').text()).toBe('姓名: 李四');
    expect(wrapper.find('.age').text()).toBe('年龄: 15');
    expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 30');
  });
});

mock pinia

这时可能有小可爱会问,actions的代码上一节就测过了,岂不是重复了?其实我们也可以使用mock的方式分离业务组件和store的测试。

同样的,我们新建一个userinfo的mock文件__mocks__/userinfo.js

import { defineStore } from 'pinia';
import { vi } from 'vitest';

export const state = {
  name: '张三',
  age: 14
}

export const actions = {
  changeUserInfo: vi.fn() // mock函数
}

export const useUserInfo = defineStore('userinfo', {
  state() {
    return state
  },
  getters: {
    doubleAge: (state) => state.age * 2,
  },
  actions
})

测试文件我们修改成这样。

import {
  describe, expect, it, vi
} from 'vitest';
import { mount } from '@vue/test-utils';
import UserInfo from '@/components/UserInfo.vue';
import { createPinia } from 'pinia';
import flushPromises from 'flush-promises';
import { actions } from '@/store/userinfo.js';
const pinia = createPinia()

vi.mock('@/store/userinfo.js'); // mock

describe('测试示例', () => {
  it('vue中的pinia测试', async () => {
    const wrapper = mount(UserInfo, {
      global: {
        plugins: [pinia] // 安装pinia
      }
    });
    expect(wrapper.find('.name').text()).toBe('姓名: 张三');
    expect(wrapper.find('.age').text()).toBe('年龄: 14');
    expect(wrapper.find('.doubleAge').text()).toBe('两倍年龄: 28');

    wrapper.find('.change').trigger('click'); // 改变store
    await flushPromises();
    expect(actions.changeUserInfo).toHaveBeenLastCalledWith('李四', 15); // 断言被调用的参数
  });
});

我们采用mock函数vi.fn()记录被调用的次数,从而实现测试的独立性。

5.参考链接

  1. 源码
  2. vitest官网
  3. Vue Test Utils官网
  4. Vue测试指南