Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端测试之 Jest 单元测试 #42

Open
zenglinan opened this issue Feb 3, 2020 · 0 comments
Open

前端测试之 Jest 单元测试 #42

zenglinan opened this issue Feb 3, 2020 · 0 comments

Comments

@zenglinan
Copy link
Owner

zenglinan commented Feb 3, 2020

一、Jest 简介

  1. 优势:
    速度快、API简单、配置简单
  2. 前置:
    Jest 不支持 ES Module 语法,需要安装 babel
npm install -D @babel/core @babel/preset-env

.babelrc

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": {
          "node": "current"
        }
      }
    ]
  ]
}

jest 在运行前会检查是否安装 babel,如果安装了会去取 .babelrc 文件,结合 babel 将代码进行转化,运行转化后的代码。
3. jest 默认配置

npx jest --init
  1. jest 模式
  • jest --watchAll:当发现测试文件变动,将所有测试文件重新跑一遍
  • jest --watch:需要和 git 结合使用,会比较现有文件和 commit 的文件的差异,只测试差异文件

二、Jest 匹配器

常见匹配器

  • toBe
  • toEqual:判断对象内容是否相等
  • toMatchObject:expect(obj).toMatchObject(o),期望 o 中包含 obj
  • toBeNull
  • toBeUndefined
  • toBeDefinded
  • toBeTruthy
  • toBeFalsy
  • not:用于否定,比如 .not.toBeTruthy()

Number 相关

  • toBeGreaterThan(大于) / toBeGreaterThanOrEqual(大于等于)
  • toBeCloseTo:用于比较浮点数,近似相等时断言成立
  • toBeLessThan / toBeLessThanOrEqual

String 相关

  • toMatch:参数可以传字符串或正则

Array Set 相关

  • toContain

异常匹配器

  • toThrow:
const throwError = () => {
  throw new Error('error')
}

it('can throw error', () => {
  expect(throwError).toThrow('error') // 判断throw函数可以抛出异常,异常信息为 "error"。也可以写正则
})

这里有个小技巧:当我们想忽略掉单个文件中的其他测试用例,只针对一个测试用例做调试的时候,可以加上 .only

it.only('test', () => {
  // ...
})

但这并不会忽略其他测试文件的测试用例

三、测试异步代码

这里有三个异步方法,对这三个方法进行代码测试,"http://www.dell-lee.com/react/api/demo.json" 会返回 {success: true},
"http://www.dell-lee.com/react/api/404.json" 则不存在。

import axios from 'axios'

export function getData1() {
  return axios.get('http://www.dell-lee.com/react/api/demo.json')
}

export function getData2(fn) {
  axios.get('http://www.dell-lee.com/react/api/demo.json').then(res => {
    fn(res)
  })
}

export function get404() {
  return axios.get('http://www.dell-lee.com/react/api/404.json')
}

对于异步代码测试,时机很重要,必须保证我们的测试用例在异步代码走完之后才结束。有以下几种办法:

  1. done,控制测试用例结束的时机
  2. 如果函数执行的返回值是 Promise,将这个 Promise return 出去
  3. async + await
import {getData1, getData2, get404} from './fetchData/fetchData'

it('getData1 方法1', (done) => {
  getData1().then(res => {
    expect(res.data).toEqual({
      success: true
    })
    done()  // 如果不加 done,还没执行到 .then 方法,测试用例已经结束了
  })
})

it('getData1 方法2', () => {
  return getData1().then(res => {
    expect(res.data).toEqual({
      success: true
    })
  })
})

it('getData2 方法2', (done) => {
  getData2((res) => {
    expect(res.data).toEqual({
      success: true
    })
    done()
  })
})

it('getData1 方法3', async () => {
  const res = await getData1()
  expect(res.data).toEqual({
    success: true
  })
})

/*********** 重点关注 ***********/
it('get404', (done) => {
  expect.assertions(1)
  get404().catch(r => {
    expect(r.toString()).toMatch('404')
    done()
  })
})

重点讲一下上面的最后一个测试用例,假设我们现在有一个返回的是 404 的接口,我们需要对这个接口测试,期望他返回 404。
我们用 catch 捕获,在 catch 中判断。

但是,假如这个接口返回的不是 404,而是正常返回 200,这个 catch 则不会执行,expect 也不会执行,测试依然是通过的。这不符合我们的预期!所以,我们需要加上 expect.assertions(1) 进行断言:下面一定会执行一个 expect

当然,也可以用 async await 方法进行 404 接口的测试

it('get404 方法3', async () => {
  await expect(get404()).rejects.toThrow()
})

四、Jest 中的一些钩子函数

  • beforeAll:所有用例开始执行前
  • beforeEach:每个用例执行前
  • afterEach
  • afterAll
  • describe

前四个钩子使用起来很简单,调用方法如下:

beforeAll(() => {
  // ...
})

如果测试前后要做一些处理,尽可能写在这些钩子函数中,他能保证一定的执行顺序。

describe 可以用来进行用例分组,为了让我们的测试输出结果更好看,更有层次。
同时,在每个 describe 中都有上面 4 个钩子函数的存在,我们来看看具体的情况:

describe('测试 Button 组件', () => {
  beforeAll(...)  // 1
  beforeEach(...) // 2
  afterEach(...)  // 3
  afterAll(...)   // 4

  describe('测试 Button 组件的事件', () => {
    beforeAll(...)  // 5
    beforeEach(...) // 6
    afterEach(...)  // 7
    afterAll(...)   // 8
    it('event1', ()=>{...})
  })
})

上面钩子函数的执行顺序是:
1 > 5 > 2 > 6 > 3 > 7 > 4 > 8

外部的钩子函数对 describe 内部的用例也生效,执行顺序为:先外部后内部

五、Jest 中的 mock

1. 在 Jest 中 mock 异步方法

前面提到了可以测试异步代码,对于一些接口都能进行请求测试。但假如每一个接口都真的发起请求,那一次测试需要耗费的时间是很多的。
这时候我们可以模拟请求方法,步骤如下:

  1. mock.js 中导出了我们的请求方法
import axios from 'axios'

export function getData() {
  return axios.get('http://www.dell-lee.com/react/api/demo.json')
}
  1. 在 mock.js 的同级目录下建一个 mocks 的文件夹,文件夹内建立对应文件名的文件,这个文件就是导出的方法就是模拟请求的方法
    1580732355(1)
    这里我们直接返回一个 Promise,把假数据 resolve 出去
export function getData() {
  return Promise.resolve({
    success: true
  })
}
  1. 测试用例部分:
    这里有一个需要注意的jest.mock 不能写在任何钩子函数里,因为钩子函数的执行时机问题,beforeAll 也不行,当钩子函数执行时,没有写在钩子函数里面的代码已经执行了,也就是已经 import 了!
jest.mock('./mock/mock.js')  // 用 jest 模拟 mock.js 方法

import {getData} from './mock/mock.js'  // 导入 mock.js,但实际上 jest 会导入 __mocks__ 下的 mock.js

test('mock 方法测试', () => {
  getData().then(data => {
    expect(data).toEqual({
      success: true
    })
    done()
  })
  
})

除了上面的这种办法,还能在 jest.config.js 中配置自动开启 mock,这样 jest 会自动去查找当前文件同级有没有 mock 文件夹,里面有没有对应文件

module.exports = {
  automock: true
}

讲了两种 mock 的方法,还有一种极端情况需要避免 mock:

我们在 mock.js 中定义了一个需要 mock 的 getData 方法,又另外定义了一个不需要 mock 的普通方法,当我们在测试文件导入的时候,需要避免 jest 去 mocks/mock.js 下找这个普通方法,这里需要用 jest 提供的方法导入:

const { regularMethod } = jest.requireActual('./mock/mock.js')

2. 用 Jest 操控时间

当我们有如下代码需要测试的时候:

export default (fn) => {
  setTimeout(() => {
    fn()
  }, 3000)
}

我们不可能总是去等待定时器,这时候我们要用 Jest 来操作时间!步骤如下:

  1. 通过 jest.useFakeTimers() 使用 jest “自制的” 定时器,这里放在 beforeEach 里面是因为快进时间可能被调用多次,我希望在每个测试用例里,这个时钟都是初始状态,不会互相影响。
  2. 执行 timer 函数之后,快进时间 3 秒 jest.advanceTimersByTime(3000),这个方法可以调用任意次,快进的时间会叠加。
  3. 这时候我们已经穿梭到了 3 秒后,expect 也能生效了!

特别说明一下:jest.fn() 生成的是一个函数,这个函数能被监听调用过几次

import timer from './timer/timer'

beforeEach(() => {
  jest.useFakeTimers()
})

it('timer 测试', () => {
  const fn = jest.fn()
  timer(fn)
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(1)
})

3. mock 类

同样的,当我们只关注类的方法是否被调用,而不关心方法调用产生的结果时,可以 mock 类

在 util/util.js 中定义了 Util 类

export class Util {
  a() {}
  b() {}
}

在 util/useUtil 中调用了这个类

import {Util} from './util'

export function useUtil() {
  let u = new Util()
  u.a()
  u.b()
}

我们需要测试 u.a 和 u.b 被调用,jest.mock('./util/util') 会将 Util、Util.a、Util.b 都 mock 成 jest.fn

测试用例如下:

jest.mock('./util/util')  // mock Util 类
import {Util} from './util/util'
import {useUtil} from './util/uesUtil'

test('util 的实例方法被执行了', () => {
  useUtil()
  expect(Util).toHaveBeenCalled()
  expect(Util.mock.instances[0].a).toHaveBeenCalled()
  expect(Util.mock.instances[0].b).toHaveBeenCalled()
})

六、结合 Vue组件 进行单元测试

1. 简单用例入门

Vue 提供了 @vue/test-utils 来帮助我们进行单元测试,创建 Vue 项目的时候勾选测试选项会自动帮我们安装。

先来介绍两个常用的挂载方法:

  • mount:会将组件以及组件包含的子组件都进行挂载
  • shallowMount:浅挂载,只会挂载组件,忽略子组件

再来看一个简单的测试用例:

import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.props('msg')).toBe(msg)
  })
})

shallowMount 会返回一个 wrapper,这个 wrapper 上面会包含很多帮助我们测试的方法,详见

2. 快照测试

快照测试的意思是,会将组件像拍照一样拍下来,存底。下次运行测试用例的时候,如果组件发生变化,和快照不一样了,就会报错。

测试用例写法如下:
第一次测试会保存 wrapper 的快照,第二次会比较当前 wrapper 和快照的区别

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper).toMatchSnapshot()
  })
})

我们再来看看快照长什么样子:
1580787793(1)
可以看到,快照实际保存的就是组件渲染之后的 html 部分,css 部分没有保存,在元素上绑定的 @click 等一些事件也不会保存,
所以快照适合进行 DOM 节点是否变化的测试。

当快照发生变化时,我们可以在终端按 u 进行更新快照
1580788050(1)

覆盖率测试

覆盖率测试是对测试完全程度的一个评估,测试覆盖到的业务代码越多,覆盖率越高。

在 jest.config.js 中我们可以设置 collectCoverageFrom,来设置需要进行覆盖率测试的文件,这里我们测试一下所有的 .vue 文件,忽略 node_modules 下所有文件。

要注意,在 Vue 中配置 jest,参考文档

然后添加一条 script 命令,就能进行测试了:

"test:unit": "vue-cli-service test:unit --coverage"

执行命令会生成 coverage 文件夹,Icov-report/index.html 里会可视化展示我们的测试覆盖率

七、写在最后

1. 单元测试 or 集成测试?

就拿 shallowMount 来说,这个 api 就很适合单元测试,单元测试不关注单元之间的联系,对每个单元进行独立测试,
这也使得它代码量大,测试间过于独立。在进行一些函数库的测试,各个函数比较独立的时候,就很适合单元测试。

在进行一些业务组件测试时,需要关注组件间的联系,比较适合用集成测试。

2. TDD or BDD?

TDD:测试驱动开发,先写测试用例,然后根据用例写代码,比较关注代码本身。如下:

describe('input 输入回车,向外触发事件,data 中的 inputValue 被赋值', () => {
  const wrapper = shallowMount(TodoList)
  const inputEle = wrapper.find('input').at(0)
  const inputContent = '用户输入内容'
  inputEle.setValue(inputContent)
  // expect:add 事件被 emit
  except(wrapper.emitted().add).toBeTruthy()
  // expect:data 中的 inputValue 被赋值为 inputContent
  except(wrapper.vm.inputValue).toBe(inputContent)
})

TDD 关注代码内部如何实现,关注事件是否触发?属性是否设置?data 数据是否被更新?

BDD:用户行为驱动开发,先写完业务代码,然后站在用户的角度去测试功能,不关注代码实现过程,只是通过模拟用户操作测试功能

比如下面这个用例:

describe('TodoList 测试', () => {
  it(`
    1. 用户在 header 输入框输入内容
    2. 键盘回车
    3. 列表项增加一项,内容为用户输入内容
  `, () => {
    // 挂载 TodoList 组件
    const wrapper = mount(TodoList)
    // 模拟用户输入
    const inputEle = wrapper.find('input').at(0)
    const inputContent = '用户输入内容'
    inputEle.setValue(inputContent)
    // 模拟触发的事件
    inputEle.trigger('content')
    inputEle.trigger('keyup.enter')
    // expect:列表项增加对应内容
    const listItems = wrapper.find('.list-item')
    expect(listItems.length).toBe(1)  // 增加 1 项
    expect(listItems.at(0).text()).toContain(inputContent)  // 增加 1 项
  })
})
@zenglinan zenglinan changed the title Jest 单元测试学习实践 前端测试之 Jest 单元测试 Feb 4, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant