【测试一下!】vue3的单元测试(三)-vue-test-util实战篇

740 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
之前两篇是一些环境搭建和语法基础
下面开始结合实际组件进行组件的单元测试编写

开始

新建组件

比如我们有一个JestUnitTest的组件

<template>
  <!-- 基础测试 -->
  <h1>{{ msg }}</h1>
  <button @click="setCount">{{ count }}</button>
  <!-- 测试异步请求 -->
  <button class="loadUser" @click="loadUser">load</button>
  <p v-if="user.loading" class="loading">Loading</p>
  <div v-else class="userName">{{ user.data && user.data.name }}</div>
  <!-- 测试store -->
  <button class="storeTest" @click="storeTest">load</button>
  <!-- 测试router -->
  <button class="routerTest" @click="routerTest">router</button>
  <!-- 测试组件库message -->
  <button class="comMessage" @click="comMessage">message</button>
</template>

<script lang="ts">
  import { defineComponent, reactive, ref } from 'vue';
  import { useUserStore } from '@/store/index';
  import { useRouter } from 'vue-router';
  import { message } from 'ant-design-vue';

  import axios from 'axios';
  export default defineComponent({
    name: 'JestUnitTest',
    props: {
      msg: String,
    },
    emits: ['emitTest'],
    setup(props, context) {
      const todo = ref('');
      const userStore = useUserStore();
      userStore.setName('23123');

      const user = reactive({
        data: null as any,
        loading: false,
        error: false,
      });
      const count = ref(1);
      const setCount = () => {
        context.emit('emitTest', count.value);
        count.value++;
      };

      const storeTest = () => {
        userStore.increment();
      };

      const loadUser = () => {
        user.loading = true;
        axios
          .get('http://localhost:7777/userMsg')
          .then((resp) => {
            user.data = resp.data;
          })
          .catch(() => {
            user.error = true;
          })
          .finally(() => {
            user.loading = false;
          });
      };
      const router = useRouter();
      const routerTest = () => {
        router.push('/test');
        console.log(123);
      };

      const comMessage = () => {
        message.success('This is a normal message');
      };

      return {
        count,
        setCount,
        todo,
        user,
        storeTest,
        loadUser,
        routerTest,
        comMessage,
      };
    },
  });
</script>

所有测试用例都在下文,可以随时翻上来看具体方法和dom

开始测试

首先我们测试组件正常接受参数msg,并且展示正确

describe('JestUnitTest.vue', () => {
  it('测试参数接受正确', () => {
    const wrapper = shallowMount(JestUnitTest, {
      props: { msg: '测试参数' },
    });
    console.log(wrapper.html());
    expect(wrapper.get('h1').text()).toBe('测试参数');
  });
});

测试用例通过
image.png

此时打印也可以看出 search组件内部的内容并没有被渲染,也符合我们之前说的shallowMount 只会渲染组件本身

测试点击事件成功触发并且修改参数正确

it('测试点击事件', async () => {
    const wrapper = shallowMount(JestUnitTest, {
      props: { msg: '测试参数' },
    });
    await wrapper.get('button').trigger('click');
    expect(wrapper.get('button').text()).toBe('2');
  });

测试用例通过
image.png

触发事件使用 tigger
因为vue修改dom是异步的所以需要await 触发事件

提取公共逻辑

发现两个测试用例中有重复逻辑
我们想将逻辑提取出来
jest的几个生命周期钩子

  • beforeAll(()=>{}) 所有用例执行前,执行一次
  • afterAll() 所有用例执行后,执行一次
  • beforeEach() 所有用例执行前,执行多次
  • afterEach() 所有用例执行后,执行多次

下面将wrapper的逻辑提取到beforeAll里面

let wrapper: VueWrapper<any>;

describe('JestUnitTest.vue', () => {
  beforeAll(() => {
    wrapper = shallowMount(JestUnitTest, {
      props: { msg: '测试参数' },
    });
  });
  it('测试参数接受正确', () => {
    expect(wrapper.get('h1').text()).toBe('测试参数');
  });
  it('测试点击事件', async () => {
    await wrapper.get('button').trigger('click');
    expect(wrapper.get('button').text()).toBe('2');
  });
});

测试组件中emit触发事件

 it('测试组件触发事件', async () => {
    await wrapper.get('button').trigger('click');
    console.log(wrapper.emitted());
    const events = wrapper.emitted('emitTest');
    expect(events?.[0]).toEqual([1]);
  });

image.png

emitTest事件被触发,value为二维数组,因为上面有测试点击的用例,加上本次用例里面的触发点击,所以一共触发了两次,所以数组长度为2

测试组件中的异步请求和展示

请求我们这里可以用jsonplaceholder或者使用json-server启动一个json服务来模拟返回。
这里我们用json-server启动

json-server tests/test/test.json --port 7777

然后mock一下请求第三方库,这里用的是axios

import axios from 'axios';
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;// 使axios保留原来的类型,同时增加mock的类型

describe('JestUnitTest.vue', () => {
...
it('测试组件中的异步请求', async () => {
    mockAxios.get.mockResolvedValueOnce({ data: { name: '被mock的返回' } });
    await wrapper.get('.loadUser').trigger('click');
    expect(mockAxios.get).toHaveBeenCalled();//测试get 方法是否被执行
    expect(wrapper.find('.loading').exists()).toBeFalsy();// 测试loading是否还存在
  });
  afterEach(() => {
    mockAxios.get.mockReset();
  });
}

afterEach 里面可以调用mockReset方法重置下mock,避免副作用

这时候有个报错,发现 expect(wrapper.find('.loading').exists()).toBeFalsy()没有通过
按理说,当数据返回后,loading应该消失,现在没有通过用例,说明用例的执行并没有等到结果回来。
这时候需要借助第三方库flush-promises
改造一下

import flushPromises from 'flush-promises';
it('测试组件中的异步请求', async () => {
    mockAxios.get.mockResolvedValueOnce({ data: { name: '被mock的返回' } });
    await wrapper.get('.loadUser').trigger('click');
    expect(mockAxios.get).toHaveBeenCalled();
    await flushPromises();
    expect(wrapper.find('.loading').exists()).toBeFalsy();
 });

现在测试用例符合预期了

测试pinia/vuex

下面继续来测试下我们项目中用到的状态库
这里用到的是pinia,vuex大致处理差不多,有些细节不一样,后续增加vuex的说明

我们的userStore文件如下

import { defineStore } from 'pinia';
import { UserStore } from './index.type';

export const useUserStore = defineStore('myStore', {
  state: (): UserStore => {
    return {
      userMsg: null,
      isLogin: true,
      name: 'test',
      n: 0,
    };
  },
  actions: {
    setUserMsg(user: UserStore['userMsg']) {
      this.userMsg = user;
    },
    setName(name: string) {
      this.name = name;
    },
    increment(amount = 1) {
      this.n += amount;
    },
  },

  getters: {},
});

然后新建一个测试文件,store.spec.ts

/* eslint-disable @typescript-eslint/no-explicit-any */
// https://github.com/vuejs/pinia/blob/v2/packages/testing/src/testing.spec.ts
import { mount } from '@vue/test-utils';
import { createTestingPinia, TestingOptions } from '@pinia/testing';
import { defineStore } from 'pinia';
import JestUnitTest from '@/components/JestUnitTest.vue';
import { useUserStore } from '@/store/modules/user/index';

describe('JestUnitTest.vue', () => {
  function factory(options?: TestingOptions) {
    const wrapper = mount(JestUnitTest, {
      global: {
        plugins: [createTestingPinia(options)],
      },
      props: { msg: '测试参数' },
    });

    const myStore = useUserStore();
    const counter = useCounter();

    return { wrapper, myStore };
  }

  it('测试store的actions方法', async () => {
    const { myStore } = factory({
      stubActions: false,
    });

    myStore.setName('hhaha');
    expect(myStore.setName).toHaveBeenCalled();
    expect(myStore.name).toBe('hhaha');

    myStore.increment();
    expect(myStore.n).toBe(1);
    expect(myStore.increment).toHaveBeenCalledTimes(1);
    expect(myStore.increment).toHaveBeenLastCalledWith();
  });
  it('测试组件中触发store', async () => {
    const { myStore, wrapper } = factory({ stubActions: false });
    await wrapper.get('.storeTest').trigger('click');
    expect(myStore.increment).toHaveBeenCalled();
    expect(myStore.increment).toHaveBeenCalledTimes(1);
    expect(myStore.n).toBe(1);
  });
});

可以看到我们这里的pinia虽然是第三方库,但是我们并没有像axios一样去mock的他的实现,因为这个实际调用不会产生其他副作用。
如果我们想让pinia中的acion修改state生效我们全局注入的时候要传入参数stubActions: false

pinia我们使用的plugins 的全局注入进行测试
而vuex,我们可以使用provide的方式,其他用例基本一样

   wrapper = mount(UserProfile, {
    props: { msg: '测试参数' },
      global: {
        provide: {
          store
        }
      }
    })

vue-router和第三方组件库的处理

为什么将vue-router和第三方组件库比如ant-design-vue放一起,是因为这里都是要mock他们的实现,而不像pinia一样,直接实际调用

vuex测试
先mock一下,mock函数的第二个参数就是里面方法的具体实现

//mock vue-router同时重写push方法
const mockedRoutes: string[] = [];
jest.mock('vue-router', () => ({
  useRouter: () => ({
    push: (url: string) => mockedRoutes.push(url),
  })
 })
  ...
  
  it('测试vue-router', async () => {
    const { myStore, wrapper } = factory({ stubActions: false });
    await wrapper.get('.routerTest').trigger('click');
    expect(mockedRoutes).toEqual(['/test']);
  });

第三方库,比如ant-design-vue 有一个message方法,我们测试一下是否成功调用

// 引入message
import { message } from 'ant-design-vue';
//mock, 重写message方法
jest.mock('ant-design-vue', () => ({
  message: {
    success: jest.fn(),
  },
}));

...

it('测试组件库mesage', async () => {
    const { myStore, wrapper } = factory({ stubActions: false });
    await wrapper.get('.comMessage').trigger('click');
    expect(message.success).toHaveBeenCalled();
});

现在用例全部通过了

image.png

TDD

以上我们的开发方式都是先编写代码,然后对代码进行后续补充单元测试用例。
除了这种方式之外还有一种其他的开发方式- TDD开发模式

Test Driven Development - 测试驱动开发

开发过程:

  • 先根据需求写测试用例过
  • 一开始测试用例全部是不通过状态
  • 开始写具体实现
  • 测试用例全部通过

大体是一个循环过程: 不可运行--->可运行--->重构--->不可运行--->可运行 ....

好处:

  1. 先编写代码再补充用例对开发人员的心智压力较大
  2. 可以将编程过程任务化,对进度进行精确把控

结语

通过三篇文章大致了解了vue3的单元测试
包括基础语法,环境搭建,以及如何结合具体组件进行测试
当然实际项目中可能会更加复杂,后续可能会继续更新一些复杂的组件测试用例。