自测攻略-Vue单元测试实践🔥🔥🔥

641 阅读8分钟

单元测试,顾名思义就是对软件中的最小可测试单元进行检查和验证。

一、为什么要进行前端单元测试?

  • JavaScript 缺少类型检查,编译期间无法定位到错误; JavaScript 宿主的兼容性问题。比如 DOM 操作在不同浏览器上的表现,正确性:测试可以验证代码的正确性,在上线前做到心里有底。
  • 虽然手工也可以测试,通过console可以打印出内部信息,但这是一次性的事情,下次测试还需要从头来过。自动化:通过编写测试用例,可以做到一次编写,多次运行。
  • 测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些API。其他开发人员如果要使用这些API,那阅读测试用例是一种很好地途径,解释性:有时比文档说明更清晰。
  • 互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?保证重构:有测试用例做后盾,就可以大胆的进行重构。
  • 多人开发项目时,如果每个开发人员都根据自己的实际需求对公用组件进行了一定的扩展,后期是很不便于维护的,扩展起来也容易出错。提高合作开发效率:写了单元测试的话,单元测试就相当于是一份很好的业务逻辑文档,有利于后续维护。

在单元测试中,常用的方法论有两个:TDD(测试驱动开发)、BDD(行为驱动开发)

二、单元测试的原则

  • 公共组件应该增加单元测试,对重点、复杂、核心代码,重点测试,确保代码质量;
  • 单元测试覆盖率不追求100%,但对于高频的功能应该增加单元测试;
  • 测试应该聚焦于函数的行为,而不是具体的实现;
  • 数据尽量模拟现实,越靠近现实越好,充分考虑数据的边界条件;
  • 利用AOP(beforeEach、afterEach),减少测试代码数量,避免无用功能;

测试广义上分为黑盒测试和白盒测试。单元测试属于后者

  1. BDD:Behavior Driven Development,行为驱动开发,测试用例模拟用户的操作行为,通常在完成业务代码开发之后,以用户的操作指导编写测试代码,关注点稍微偏重于测试与技术细节;
  2. TDD:Test-Driven Development,测试驱动开发,这种模式中,先编写测试用例,在测试用例的指导下去完善功能,关注点偏重于业务流程;

三、实践前的知识

主流的单元测试运行器有很多,比如 JestMocha 和 Karma 等,这几个在 Vue-Test-Utils 文档里都有对应的教程,这里只介绍 Vue-Test-Utils + Jest 结合的示例。

本篇采用的测试工具:

  • vue-test-utils
  • jest

Jest 是一个由 Facebook 开发的测试框架,是一个 JavaScript 测试框架,内置断言且命令行的用户体验非常好。

Vue Test Utils 是 Vue.js 官方的单元测试实用工具库。

扩展:mocha是一个javascript的测试框架,chai是一个断言库,两者搭配使用更佳,所以合称“抹茶”

安装依赖:

 npm install --save-dev vue-jest
npm install --save-dev jest

配置Jest:

Jest 的配置可以新建一个文件 jest.config.js, 放在项目根目录即可。如下:

module.exports = {

  transform: {

    "^.+\.vue$": "vue-jest",

  },

  moduleNameMapper: {

    "/^@/(.*)$/": "<rootDir>/src/$1"

  },

}; 

各配置项说明:

  • transform 匹配到 .vue 文件的时候用 vue-jest 处理, 匹配到 .js 文件的时候用 babel-jest 处理
  • moduleNameMapper 处理 webpack 的别名,比如:将 @ 表示 /src 目录

其他配置可参考官网

启动执行命令:

npm run test:unit

测试 ts 文件:

jest 需要借助 ts-jest,再进行测试,需要安装依赖:

module.exports = {

  transform: {

    "^.+\.vue$": "vue-jest",

  },

  moduleNameMapper: {

    "/^@/(.*)$/": "<rootDir>/src/$1"

  },

}; 

各配置项说明:

  • transform 匹配到 .vue 文件的时候用 vue-jest 处理, 匹配到 .js 文件的时候用 babel-jest 处理
  • moduleNameMapper 处理 webpack 的别名,比如:将 @ 表示 /src 目录

其他配置可参考官网

启动执行命令:

npm run test:unit

测试 ts 文件:

jest 需要借助 ts-jest,再进行测试,需要安装依赖:

yarn add ts-jest //安装依赖
yarn ts-jest config:init  //运行

持续监听:

为了提高效率,可以通过加启动参数的方式让 jest 持续监听文件的修改,而不需要每次修改完再重新执行测试用例,改写 package.json:

"scripts": { "test": "jest --watchAll" },

更多详情可参考官方文档:v1.test-utils.vuejs.org/zh/

基本测试

  • 获取页面元素的方法:find, findall,get,使用queryselector语法;

  • 组件挂载:

    • mount,渲染组件,包括子组件;
    • shallowMount,浅渲染,渲染组件,但不渲染子组件;
  • 断言:

    • 断言的方法:toBe,toEqual,toMatch
    • 其他方法枚举

断言:可以理解为断定一个表达式结果为真,不为真就通过抛异常或者其他方式使这个测试用例失败

  • 测试样例:
describe("Base unit test cases", ()=> {

  const actions = {

    asyncUpdate: jest.fn(),

  }

  const store = new Vuex.Store({

    state: {

      module1: {

      package: {

        build: 'npm' 

      },

    }

    },

    actions

  })



  it("基本用例:组件挂载mount,组件定位,断言", () => {

    const parentWrapper = mount(Parent, { 

      global: {

        plugins: [store],

      } 

    })

    const childMessage = parentWrapper.find("#child-message");

    expect(childMessage.text()).toMatch("Message from Child: message from child")

  });



  it("基本用例:浅挂载shallowMount,组件定位,断言", () => {

    const shallowParentWrapper = shallowMount(Parent, { 

      global: {

        plugins: [store],

      } 

    })

    const greet = shallowParentWrapper.find("#greet");

    expect(greet.text()).toMatch("Message from Parent: greet from parent")

  });})

讲解:

  1. describe(name, fn) 这边是定义一个测试套件,Base unit test cases 是测试套件的名字,fn 是具体的可执行的函数;
  2. it(name, fn) 是一个测试用例,"基本用例:组件挂载mount,组件定位,断言","基本用例:浅挂载shallowMount,组件定位,断言"是测试用例的名字,fn 是具体的可执行函数;一个测试套件里可以保护多个测试用例。

一个测试块里可以有多个测试用例,依次执行

  1. expect 是 Jest 内置的断言风格,它接受一个参数,就是运行测试内容的结果,返回一个对象,这个对象来调用匹配器,匹配器的参数就是我们预期的结果。

业界还存在别的断言风格比如 Should、Assert 等。

  1. toMatch 是 Jest 提供的断言方法, 更多的可以到Jest Expect 查看具体用法。
  2. 使用find获取到DOM元素;
  3. 字符型匹配:toMatch 匹配规则,支持正则表达式匹配;

mount与shallowMount的不同?

  • mount会渲染整个组件树,而shallowMount在挂载组件之前对所有子组件进行存根。
  • shallowMount可以确保你对一个组件进行独立测试,有助于避免测试中因子组件的渲染输出而混乱结果。

优化建议:shallowMount 没有渲染真实的 DOM 节点,所以比较轻量级,Mount 渲染出来的是完整的DOM节点,速度比较慢,所以能用shallowMount就不用mount。

mount适合小组件测试,shallowMount适合多场景测试

组件测试

通过props进行参数传递:

  • 测试样例:
describe("Component unit test cases", () => {

    const factory = (propsData) => {

        return shallowMount(Child, {

            propsData: {

                ...propsData

            }

        })

    }



    it("组件属性传递,根据属性渲染组件", () => {

        const childWrapperAdmin = factory({isAdmin: true});

        const spanAdmin = childWrapperAdmin.find("span")

        expect(spanAdmin.text()).toMatch("Child: admin should see this")



        const childWrapperUser = factory();

        const spanUser = childWrapperUser.find("span")

        expect(spanUser.text()).toMatch("Child: admin should not see this")

    });})

交互测试

  • DOM事件

    • trigger方法,用于模拟触发组件事件
    • setValue方法,用于模拟设置组件的值
    • emit方法,用于测试产生的dom事件
  • 异步测试

    • async,await,用于测试异步的逻辑
    • 涉及dom变更的测试(trigger等),需要通过async+await实现,否则容易出现测试结果不稳定的情况
  • 测试样例:

describe("Interactive unit test cases", () => {

    const factory = (propsData) => {

        return shallowMount(Child, {

            propsData: {

                ...propsData

            }

        })

    }



    it("模拟组件交互,测试组件事件", async () => {

        const childWrapperUser = factory();

        const button = childWrapperUser.find("#childButton1")

        const message = childWrapperUser.get("#child-message")



        await button.trigger("click")



        expect(message.text()).toBe("Message from Child: Start notify parent")

        expect(childWrapperUser.emitted()).toHaveProperty('notify')

        expect(childWrapperUser.emitted('notify')).toHaveLength(1)

        expect(childWrapperUser.emitted('notify')[0][0]).toEqual("Start notify parent")

    });
  1. toBe(value)判断值是否相等,但不能用来判断对象。
  2. trigger(eventType,options):触发DOM事件,触发的事件是同步的,返回的是一个promise。eventType即触发的事件类型;options为传入的参数。

vuex测试

  • vuex的函数跟常规的函数测试类似
  • 测试样例:
describe('Vuex单元测试', () => {

    it('vuex mutations测试', () => {

        const state = {

            package: {

                build: 'npm',

                env: 'prod'

            }

        }

        updateBuild(state, "yarn")

        expect(state.package.build).toEqual("yarn")

    })

 })

讲解:

  1. 判断对象是否相等,可以采用toEqual(value)

vue-router测试

Wrapper 是一个对象,该对象包含了一个挂载的组件或 vnode,以及测试该组件或 vnode 的方法

  • 测试样例
const router = createRouter({

    history: createWebHistory(),

    routes: routes,})describe("Vue router unit test cases", () => {

    it("路由测试", async () => {

        const actions = {

            asyncUpdate: jest.fn(),

        }

        const store = new Vuex.Store({

            state: {

                module1: {

                    package: {

                        build: 'npm'

                    },

                }

            },

            actions

        })



        router.push('/')

        await router.isReady()



        const wrapper = mount(App, {

            global: {

                plugins: [router, store]

            }

        })

        expect(wrapper.html()).toContain('Vue unit test')

        await wrapper.find('a').trigger('click')

        await flushPromises()

        expect(wrapper.html()).toContain('Message from Parent')

    });})

讲解:

  1. 数组类型匹配:toContain 检查是否包含;
  2. wrapper.find(selector): 接收一个选择器作为参数,返回包裹器下匹配的第一个DOM节点或者vue组件。
  3. 使用路由测试后,不需要自己在页面点击,自动化测试,还是很便捷的;
  4. jest.fn():该方法是创建Mock函数最简单的方式,可以自定义函数内部的实现及返回值,若是没有定义,则返回undefined。

mock http请求

  • 通过mock来规避对后端服务的依赖
  • 测试样式:
const mockItemList = ["item-http3", "item-http4", "item-http5"]jest.spyOn(axios, 'get').mockResolvedValue(mockItemList)describe("Http mock unit test cases", () => {

    const factory = (propsData) => {

        return shallowMount(Child, {

            propsData: {

                ...propsData

            }

        })

    }



    it("mock http请求", async () => {

        const childWrapperAdmin = factory();

        await childWrapperAdmin.get('#childButton2').trigger('click')



        expect(axios.get).toHaveBeenCalledTimes(1)

        expect(axios.get).toHaveBeenCalledWith('/api/items')



        await flushPromises()

        const items = childWrapperAdmin.findAll('[li-content="items"]')

        expect(items).toHaveLength(3)

      })

  })

讲解:

  1. .toHaveBeenCalledTimes(number)使用这个方法可以确保模拟函数调用的确切次数。
  2. .toHaveBeenLastCalledWith(arg1, arg2, ...):如果有一个模拟函数,这个方法可以测试最后调用它的参数是什么。

以上就是本篇文章的全部内容,如果有不同的看法,欢迎大家补充评论~

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情