关于Vue单元测试的一些总结

Flower Shop(Rosuuri from Pixiv)

最近Vue项目要求用到单元测试,就这段时间用到的一些单元测试知识做一下总结。

TDD和BDD的概念

1.TDD(Test Driven Development),即测试驱动开发,简单的来说就是先编写测试代码,然后以使得所有测试代码都通过为目的,编写逻辑代码,是一种以测试来驱动开发过程的开发模式。

2.BDD(Behavior Driven Development),即行为驱动开发,简单的来说就是先编写业务逻辑代码,然后以使得所有业务逻辑按照预期结果执行为目的,编写测试代码,是一种以用户行为来驱动开发过程的开发模式。

3.集成测试(Integration Testing),是指对软件中的所有模块按照设计要求进行组装为完整系统后,进行检查和验证。通俗的讲,在前端,集成测试可以理解为对多个模块实现的一个交互完整的交互流程进行测试。对于多个模块(ES6模块)组成的系统,需要首先将交互行为完善,才能按照预期行为编写测试代码。所以提到BDD,这里的测试一般是指集成测试。

单元测试要测什么

测试的目的:Vue官网《明白要测试的是什么》这一节
测试方向简单来说就几点:
①测试函数是否被调用(自定义事件$emit,method中的方法)
②props、样式是否生效
③watch监听是否生效
④DOM结构是否存在

环境配置

1.jest

在原有vue-cl安装了单元测试i基础上,可以直接添加jest,添加后会在目录下创建jest.config.js用于配置和预设。

vue add @vue/unit-jest

其中配置项主要有这几点(我的项目为例)

module.exports = {
  preset: '@vue/cli-plugin-unit-jest',
  moduleNameMapper: {
    Swiper: '<rootDir>/tests/unit/__mocks__/Swiper.js',  // 在mocks文件夹创建假module.exports 导出第三方框架 
    echarts: '<rootDir>/tests/unit/__mocks__/echarts.js',
    BaseUI: '<rootDir>/tests/unit/__mocks__/baseui.js',
    '^@\/(.*?\.?(js|vue)?|)$': '<rootDir>/src/$1', // @路径转换,例如:@/components/Main.vue -> rootDir/src/components/Main.vue
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/tests/unit/__mocks__/fileMock.js', // 模拟加载静态文件
    '\\.(css|less|scss|sass)$': '<rootDir>/tests/unit/__mocks__/styleMock.js' // 模拟加载样式文件
  },
  setupFilesAfterEnv: ['<rootDir>/tests/unit/__mocks__/setup-tests.js'],
  moduleFileExtensions: [
    'js',
    'json',
    // 告诉 Jest 处理 `*.vue` 文件
    'vue'
  ],
  transform: {
    // 用 `vue-jest` 处理 `*.vue` 文件
    '.*\\.(vue)$': 'vue-jest',
    // 用 `babel-jest` 处理 js
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest'
  },
  collectCoverage: true,
  collectCoverageFrom: [
    '!**/src/components/editable-tabs/editable-tabs.vue**',
    '!**/src/components/HelloWorld.vue**',
    '!**/src/components/all-modal/menu-modal/menu-modal.vue**',
    '!**/src/basic-comps/base-echarts/base-echarts.vue**',
    '!**/src/basic-comps/basic-swiper/basic-swiper.vue**',
    '!**/src/basic-comps/sys-menu/sys-menu.vue**',
    // '**/*.{vue,js,jsx}',
    '**/src/basic-comps/**/*.{vue,jsx}',
    '**/src/components/**/*.{vue,jsx}',
    // '**/src/utils/*.{js,jsx}',
    '!**/node_modules/**',
    '!**/doc/**',
    '!**/vendor/**',
    '!**/dist/**'
  ]
}

testMatch - 匹配测试用例的文件
transform - 用 vue-jest 处理 *.vue 文件,用babel-jest 处理 *.js 文件
moduleNameMapper - 支持源代码中相同的 @ -> src 别名
coverageDirectory - 覆盖率报告的目录,测试报告所存放的位置
collectCoverageFrom - 测试报告想要覆盖那些文件,目录,前面加!是避开这些文件

2.Vue-Test-Utils

建议查看官网api和教程:https://vue-test-utils.vuejs.org/zh/guides/

测试demo

项目大佬写的demo,开头参考文章是了解各种语法和函数、钩子的作用的资料

/**
 * 参考文章:
 * https://vue-test-utils.vuejs.org/zh/api/wrapper/
 * https://www.jianshu.com/p/ad87eaf54622
 * https://juejin.im/post/6856730547969622024#heading-5
 * https://juejin.im/post/6844904196244766728#heading-37
 * https://juejin.im/post/6844904051449036808#heading-11
 */
import { createLocalVue, shallowMount } from '@vue/test-utils'
import ElementUI from 'element-ui'
import Demo from '@/components/demo/demo.vue'

const localVue = createLocalVue()
localVue.use(ElementUI)

describe('Demo.vue', () => {
  let wrapper = null
  const msg = 'new message'
  const mocks = {
    $router: {
      push: jest.fn()
    }
  }

  beforeEach(() => {
    // shallowMount创建的组件,只关注当前的组件,不应该关注其子组件的情况
    wrapper = shallowMount(Demo, {
      localVue,
      propsData: { msg },
      slots: {
        default: '<section class="content">What an awesome section</section>'
      },
      mocks: mocks // 将全局属性存根, 然后就可以通过 wrapper.vm.$router 访问 push
    })
  })
  afterEach(() => {
    wrapper.destroy()
  })

  describe('1.测试渲染', () => {
    it('renders props.msg when passed', () => {
      expect(wrapper.text()).toMatch(msg)
    })
  })
  describe('2.测试渲染之后生成快照', () => {
    it('测试渲染之后生成快照', () => {
      expect(wrapper.vm.$el).toMatchSnapshot() // 会生把渲染之后的结果,生成一个文件
    })
  })
  describe('3.测试DOM结构', () => {
    // exists():断言 Wrapper 或 WrapperArray 是否存在。
    test('不存在img', () => {
      expect(wrapper.findAll('img').exists()) // https://vue-test-utils.vuejs.org/zh/api/wrapper/#exists
        .toBeFalsy()
    })

    // 断言 Wrapper 包含 TestMount 子组件。
    it('TestDemo组件不为空', () => {
      // https://vue-test-utils.vuejs.org/zh/api/wrapper/#findcomponent
      const testMount = wrapper.findComponent({ name: 'test' }) // => finds TestDemo by component instance
      expect(testMount.exists())
        .toBe(true)
    })

    // attributes():返回 Wrapper DOM 节点的特性对象
    // classes():返回 Wrapper DOM 节点的 class 组成的数组
    // it('TestDemo组件有 test-demo 类', () => {
    // console.log(wrapper.find('.test-demo'))
    // console.log(wrapper.find('.test-demo').attributes()) // { class: 'test-demo btn', style: 'width: 100%;' }
    // console.log(wrapper.find('.test-demo').classes()) // [ 'test-demo', 'btn' ]

    expect(wrapper.find('.test-demo').attributes().class) // // https://vue-test-utils.vuejs.org/zh/api/wrapper/#attributes
      .toContain('btn')

    // expect(wrapper.find('.test-demo').classes()) // https://vue-test-utils.vuejs.org/zh/api/wrapper/#classes
    //   .toContain('test-demo')

    // })
  })
  describe('4.测试样式', () => {
    test('测试样式', () => {
      expect(wrapper.find('.test-demo').attributes().style)
        .toContain('width: 100%')
    })
  })
  describe('5.测试Props', () => {
    test('测试Props', () => {
      wrapper.setProps({ msg: 'test-demo' })
      expect(wrapper.props().msg)
        .toBe('test-demo')
    })
  })
  describe('6.测试自定义事件evnet', () => {
    // 使用监听的方法,不会提示过期
    test('测试自定义事件evnet', () => {
      // 创建mock函数
      const spyHandleTitleClick = jest.spyOn(wrapper.vm, 'handleTitleClick')
      // const spyAxiosRequest = jest.spyOn(axios, 'get');
      // 触发按钮的点击事件
      wrapper.find('.title').trigger('click')
      // 判断该方法是否已经被调用
      expect(spyHandleTitleClick).toHaveBeenCalled()
      // 获取判断数据是否发生改变
      // expect(spyAxiosRequest).toBeCalledWith(axiosRequestURL, axiosRequestParams);
    })

    // 测试是否触发了emit函数
    test('测试自定义事件evnet', () => {
      const spyEmit = jest.spyOn(wrapper.vm, '$emit')
      wrapper.find('.title').trigger('click')
      expect(spyEmit).toHaveBeenCalled()
      expect(spyEmit).toHaveBeenCalledTimes(2)
    })
  })
  describe('7.测试计算属性computed', () => {
    test('1.测试formatNunber', () => {
      wrapper.setData({ number: 10 })
      expect(wrapper.vm.formatNumber).toBe('数值=10') // formatNumber 是计算属性
    })

    test('2.测试formatNunber', () => {
      wrapper.find('.btn-number').trigger('click')
      expect(wrapper.vm.formatNumber).toBe('数值=2')
    })
  })
  describe('8.测试监听器watch', () => {
    test('测试watch number', (done) => {
      const spy = jest.spyOn(console, 'log')
      wrapper.vm.number = 20 // change number

      // watch number
      // watch中的方法被Vue**推迟**到了更新的下一个循环队列中去异步执行,如果这个watch被触发多次,只会被推送到队列一次。
      // 这种缓冲行为可以有效的去掉重复数据造成的不必要的性能开销。
      // 所以当我们设置了inputValue为'ok'之后,watch中的方法并没有立刻执行

      wrapper.vm.$nextTick(() => {
        expect(spy)
          .toBeCalled()
        // 清除数据
        spy.mockClear()
        done()
      })
    })
  })
  // jest.mock('axios')
  // 在测试时要避免一切的依赖,将所有的依赖都mock掉
  describe('9.测试方法method', () => {
    test('测试 handleTitleClick 方法methcd,推荐', () => {
      // 创建mock函数
      const spyHandleTitleClick = jest.spyOn(wrapper.vm, 'handleTitleClick')
      // 触发按钮的点击事件
      wrapper.find('.title').trigger('click')
      // 判断该方法是否已经被调用
      expect(spyHandleTitleClick)
        .toHaveBeenCalled()
    })
  })

  // https://vue-test-utils.vuejs.org/zh/api/wrapper/#findall
  describe('10.测试插槽slot(其实就是测试dom)', () => {
    test('测试header插槽slot', () => {
      const header = wrapper.find('header') // 查找header标签
      expect(header.text()).toBe('What an awesome header')
    })
    test('测试default插槽slot', () => {
      expect(wrapper.findAll('.content').exists())
        .toBeTruthy()
    })
    test('测试footer插槽slot', () => {
      expect(wrapper.find('footer').exists())
        .toBeTruthy()
    })
  })
})

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342